From 2a0f884ef386aa284a712ba7e1691a9c1963611d Mon Sep 17 00:00:00 2001 From: Branislav Katreniak Date: Tue, 28 Oct 2025 08:54:48 +0100 Subject: [PATCH] feat: add asyncLocalStorage option for external AsyncLocalStorage injection Add an asyncLocalStorage plugin option to allow injecting an external AsyncLocalStorage instance. This enables sharing a single AsyncLocalStorage across multiple request sources in applications that handle requests from various origins. Use cases: - Fastify HTTP requests - Queue consumers - Scheduled tasks and timers - Other HTTP servers or frameworks Previously, the plugin created its own AsyncLocalStorage instance, making it difficult to maintain a unified request context across different entry points. By providing the asyncLocalStorage option, users can now create a central AsyncLocalStorage instance and share it across all request handlers, without coupling non-HTTP code to Fastify. Usage: ``` const sharedALS = new AsyncLocalStorage() app.register(fastifyRequestContext, { asyncLocalStorage: sharedALS }) ``` Breaking change: None. * The plugin maintains backward compatibility by using a default AsyncLocalStorage instance when the setter is not called. * Exported asyncLocalStorage is untouched. --- README.md | 21 ++++++++++- index.js | 46 +++++++++++++---------- test-tap/requestContextPlugin.e2e.test.js | 38 ++++++++++++++++++- types/index.d.ts | 1 + 4 files changed, 85 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 0b081f1..337ad9c 100644 --- a/README.md +++ b/README.md @@ -72,11 +72,12 @@ fastify.register(fastifyRequestContext, { }); ``` -This plugin accepts options `hook` and `defaultStoreValues`, `createAsyncResource`. +This plugin accepts options `hook`, `defaultStoreValues`, `createAsyncResource`, and `asyncLocalStorage`. * `hook` allows you to specify to which lifecycle hook should request context initialization be bound. Note that you need to initialize it on the earliest lifecycle stage that you intend to use it in, or earlier. Default value is `onRequest`. * `defaultStoreValues` / `defaultStoreValues(req: FastifyRequest)` sets initial values for the store (that can be later overwritten during request execution if needed). Can be set to either an object or a function that returns an object. The function will be sent the request object for the new context. This is an optional parameter. * `createAsyncResource` can specify a factory function that creates an extended `AsyncResource` object. +* `asyncLocalStorage` allows injecting an external `AsyncLocalStorage` instance to share context across multiple request sources (e.g., HTTP, queues, scheduled tasks). From there you can set a context in another hook, route, or method that is within scope. @@ -164,6 +165,24 @@ it('should set request context', () => { }) ``` +## Sharing context across request sources + +To share context between Fastify and other request sources (queues, scheduled tasks), inject an external `AsyncLocalStorage`: + +```js +const { AsyncLocalStorage } = require('node:async_hooks'); +const sharedStorage = new AsyncLocalStorage(); + +app.register(fastifyRequestContext, { asyncLocalStorage: sharedStorage }); + +// Queue consumer using the same storage +function handleQueue(msg) { + sharedStorage.run({ traceId: msg.traceId }, () => processMessage(msg)); +} +``` + +**Note:** When using an external `AsyncLocalStorage`, the static `requestContext` and `asyncLocalStorage` exports will remain independent and won't share state with your external instance. + ## License Licensed under [MIT](./LICENSE). diff --git a/index.js b/index.js index cb86172..be430c7 100644 --- a/index.js +++ b/index.js @@ -8,25 +8,33 @@ const asyncResourceSymbol = Symbol('asyncResource') const asyncLocalStorage = new AsyncLocalStorage() -const requestContext = { - get: (key) => { - const store = asyncLocalStorage.getStore() - return store ? store[key] : undefined - }, - set: (key, value) => { - const store = asyncLocalStorage.getStore() - if (store) { - store[key] = value - } - }, - getStore: () => { - return asyncLocalStorage.getStore() - }, +function createRequestContext(storage) { + return { + get: (key) => { + const store = storage.getStore() + return store ? store[key] : undefined + }, + set: (key, value) => { + const store = storage.getStore() + if (store) { + store[key] = value + } + }, + getStore: () => { + return storage.getStore() + }, + } } +const requestContext = createRequestContext(asyncLocalStorage) + function fastifyRequestContext(fastify, opts, next) { - fastify.decorate('requestContext', requestContext) - fastify.decorateRequest('requestContext', { getter: () => requestContext }) + // Use external AsyncLocalStorage if provided, otherwise use the static one + const storage = opts.asyncLocalStorage || asyncLocalStorage + const context = opts.asyncLocalStorage ? createRequestContext(storage) : requestContext + + fastify.decorate('requestContext', context) + fastify.decorateRequest('requestContext', { getter: () => context }) fastify.decorateRequest(asyncResourceSymbol, null) const hook = opts.hook || 'onRequest' const hasDefaultStoreValuesFactory = typeof opts.defaultStoreValues === 'function' @@ -36,17 +44,17 @@ function fastifyRequestContext(fastify, opts, next) { ? opts.defaultStoreValues(req) : opts.defaultStoreValues - asyncLocalStorage.run({ ...defaultStoreValues }, () => { + storage.run({ ...defaultStoreValues }, () => { const asyncResource = opts.createAsyncResource != null - ? opts.createAsyncResource(req, requestContext) + ? opts.createAsyncResource(req, context) : new AsyncResource('fastify-request-context') req[asyncResourceSymbol] = asyncResource asyncResource.runInAsyncScope(done, req.raw) }) }) - // Both of onRequest and preParsing are executed after the als.runWith call within the "proper" async context (AsyncResource implicitly created by ALS). + // Both of onRequest and preParsing are executed after the storage.runWith call within the "proper" async context (AsyncResource implicitly created by AsyncLocalStorage). // However, preValidation, preHandler and the route handler are executed as a part of req.emit('end') call which happens // in a different async context, as req/res may emit events in a different context. // Related to https://github.com/nodejs/node/issues/34430 and https://github.com/nodejs/node/issues/33723 diff --git a/test-tap/requestContextPlugin.e2e.test.js b/test-tap/requestContextPlugin.e2e.test.js index 36e0fa5..b9e84dd 100644 --- a/test-tap/requestContextPlugin.e2e.test.js +++ b/test-tap/requestContextPlugin.e2e.test.js @@ -11,7 +11,7 @@ const { fastifyRequestContext } = require('..') const { TestService } = require('../test/internal/testService') const { test, afterEach } = require('node:test') const { CustomResource, AsyncHookContainer } = require('../test/internal/watcherService') -const { executionAsyncId } = require('node:async_hooks') +const { AsyncLocalStorage, executionAsyncId } = require('node:async_hooks') let app afterEach(() => { @@ -392,3 +392,39 @@ test('returns the store', (t) => { }) }) }) + +test('works with external AsyncLocalStorage instance', (t) => { + t.plan(3) + + const sharedStorage = new AsyncLocalStorage() + + app = fastify({ logger: true }) + app.register(fastifyRequestContext, { + asyncLocalStorage: sharedStorage, + defaultStoreValues: { source: 'http' }, + }) + + app.get('/', (req) => { + req.requestContext.set('modified', 'by-fastify') + return { + fromContext: req.requestContext.get('source'), + fromStorage: sharedStorage.getStore().modified, + } + }) + + return app.listen({ port: 0, host: '127.0.0.1' }).then(() => { + const { address, port } = app.server.address() + + return Promise.all([ + // Test Fastify HTTP request and verify req.requestContext writes to sharedStorage + request('GET', `http://${address}:${port}`).then((res) => { + t.assert.strictEqual(res.body.fromContext, 'http') + t.assert.strictEqual(res.body.fromStorage, 'by-fastify') + }), + // Test external usage (e.g., queue consumer) + sharedStorage.run({ source: 'queue' }, () => { + t.assert.strictEqual(sharedStorage.getStore().source, 'queue') + }), + ]) + }) +}) diff --git a/types/index.d.ts b/types/index.d.ts index 303cedc..8c75ef2 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -51,6 +51,7 @@ declare namespace fastifyRequestContext { defaultStoreValues?: RequestContextData | RequestContextDataFactory hook?: Hook createAsyncResource?: CreateAsyncResourceFactory + asyncLocalStorage?: AsyncLocalStorage } export const requestContext: RequestContext