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