Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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).
Expand Down
46 changes: 27 additions & 19 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this needed? we create it later again


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'
Expand All @@ -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
Expand Down
38 changes: 37 additions & 1 deletion test-tap/requestContextPlugin.e2e.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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')
}),
])
})
})
1 change: 1 addition & 0 deletions types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ declare namespace fastifyRequestContext {
defaultStoreValues?: RequestContextData | RequestContextDataFactory
hook?: Hook
createAsyncResource?: CreateAsyncResourceFactory
asyncLocalStorage?: AsyncLocalStorage<RequestContextData>
}

export const requestContext: RequestContext
Expand Down