Skip to content

Commit a5cbaf2

Browse files
authored
Fix context loss when async storage is instantiated early in the request lifecycle (#12)
1 parent 8a8f2d5 commit a5cbaf2

File tree

9 files changed

+470
-83
lines changed

9 files changed

+470
-83
lines changed

.eslintrc.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
"prettier"
1313
],
1414
"parserOptions": {
15-
"ecmaVersion": 2015,
15+
"ecmaVersion": 2018,
1616
"sourceType": "module"
1717
},
1818
"rules": {

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,17 @@ const { fastifyRequestContextPlugin } = require('fastify-request-context')
2727
const fastify = require('fastify');
2828

2929
fastify.register(fastifyRequestContextPlugin, {
30+
hook: 'preValidation',
3031
defaultStoreValues: {
3132
user: { id: 'system' }
3233
}
3334
});
3435
```
3536

36-
This plugin accepts option named `defaultStoreValues`.
37+
This plugin accepts options `hook` and `defaultStoreValues`.
3738

38-
`defaultStoreValues` set initial values for the store (that can be later overwritten during request execution if needed). This is an optional parameter.
39+
* `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`.
40+
* `defaultStoreValues` sets initial values for the store (that can be later overwritten during request execution if needed). This is an optional parameter.
3941

4042
From there you can set a context in another hook, route, or method that is within scope.
4143

index.d.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,28 @@
1-
import Fastify, { FastifyPlugin, FastifyRequest } from 'fastify'
1+
import { FastifyPlugin, FastifyRequest } from 'fastify'
22

33
export type RequestContext = {
44
get: <T>(key: string) => T | undefined
55
set: <T>(key: string, value: T) => void
66
}
77

8+
export type Hook =
9+
| 'onRequest'
10+
| 'preParsing'
11+
| 'preValidation'
12+
| 'preHandler'
13+
| 'preSerialization'
14+
| 'onSend'
15+
| 'onResponse'
16+
| 'onTimeout'
17+
| 'onError'
18+
| 'onRoute'
19+
| 'onRegister'
20+
| 'onReady'
21+
| 'onClose'
22+
823
export type RequestContextOptions = {
924
defaultStoreValues?: Record<string, any>
25+
hook?: Hook
1026
}
1127

1228
declare module 'fastify' {

index.test-d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ expectAssignable<RequestContextOptions>({})
1313
expectAssignable<RequestContextOptions>({
1414
defaultStoreValues: { a: 'dummy' },
1515
})
16+
expectAssignable<RequestContextOptions>({
17+
hook: 'preValidation',
18+
defaultStoreValues: { a: 'dummy' },
19+
})
1620

1721
expectType<RequestContext>(app.requestContext)
1822

lib/requestContextPlugin.js

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
const fp = require('fastify-plugin')
22
const { als } = require('asynchronous-local-storage')
3+
const { AsyncResource } = require('async_hooks')
4+
const asyncResourceSymbol = Symbol('asyncResource')
35

46
const requestContext = {
57
get: als.get,
@@ -9,12 +11,28 @@ const requestContext = {
911
function plugin(fastify, opts, next) {
1012
fastify.decorate('requestContext', requestContext)
1113
fastify.decorateRequest('requestContext', requestContext)
14+
fastify.decorateRequest(asyncResourceSymbol, null)
15+
const hook = opts.hook || 'onRequest'
1216

13-
fastify.addHook('onRequest', (req, res, done) => {
17+
fastify.addHook(hook, (req, res, done) => {
1418
als.runWith(() => {
15-
done()
19+
const asyncResource = new AsyncResource('fastify-request-context')
20+
req[asyncResourceSymbol] = asyncResource
21+
asyncResource.runInAsyncScope(done, req.raw)
1622
}, opts.defaultStoreValues)
1723
})
24+
25+
// Both of onRequest and preParsing are executed after the als.runWith call within the "proper" async context (AsyncResource implicitly created by ALS).
26+
// However, preValidation, preHandler and the route handler are executed as a part of req.emit('end') call which happens
27+
// in a different async context, as req/res may emit events in a different context.
28+
// Related to https://github.com/nodejs/node/issues/34430 and https://github.com/nodejs/node/issues/33723
29+
if (hook === 'onRequest' || hook === 'preParsing') {
30+
fastify.addHook('preValidation', (req, res, done) => {
31+
const asyncResource = req[asyncResourceSymbol]
32+
asyncResource.runInAsyncScope(done, req.raw)
33+
})
34+
}
35+
1836
next()
1937
}
2038

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"eslint-plugin-prettier": "^3.1.4",
3434
"jest": "^26.4.0",
3535
"prettier": "^2.0.5",
36+
"superagent": "^6.0.0",
3637
"tsd": "^0.13.1",
3738
"typescript": "3.9.7"
3839
},

test/internal/appInitializer.js

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
const fastify = require('fastify')
2+
const { fastifyRequestContextPlugin } = require('../../lib/requestContextPlugin')
3+
4+
function initAppGet(endpoint) {
5+
const app = fastify({ logger: true })
6+
app.register(fastifyRequestContextPlugin)
7+
8+
app.get('/', endpoint)
9+
return app
10+
}
11+
12+
function initAppPost(endpoint) {
13+
const app = fastify({ logger: true })
14+
app.register(fastifyRequestContextPlugin)
15+
16+
app.post('/', endpoint)
17+
18+
return app
19+
}
20+
21+
function initAppPostWithPrevalidation(endpoint) {
22+
const app = fastify({ logger: true })
23+
app.register(fastifyRequestContextPlugin, { hook: 'preValidation' })
24+
25+
const preValidationFn = (req, reply, done) => {
26+
const requestId = Number.parseInt(req.body.requestId)
27+
req.requestContext.set('testKey', `testValue${requestId}`)
28+
done()
29+
}
30+
31+
app.route({
32+
url: '/',
33+
method: ['GET', 'POST'],
34+
preValidation: preValidationFn,
35+
handler: endpoint,
36+
})
37+
38+
return app
39+
}
40+
41+
function initAppPostWithAllPlugins(endpoint, requestHook) {
42+
const app = fastify({ logger: true })
43+
app.register(fastifyRequestContextPlugin, { hook: requestHook })
44+
45+
app.addHook('onRequest', (req, reply, done) => {
46+
req.requestContext.set('onRequest', 'dummy')
47+
done()
48+
})
49+
50+
app.addHook('preParsing', (req, reply, payload, done) => {
51+
req.requestContext.set('preParsing', 'dummy')
52+
done(null, payload)
53+
})
54+
55+
app.addHook('preValidation', (req, reply, done) => {
56+
const requestId = Number.parseInt(req.body.requestId)
57+
req.requestContext.set('preValidation', requestId)
58+
req.requestContext.set('testKey', `testValue${requestId}`)
59+
done()
60+
})
61+
62+
app.addHook('preHandler', (req, reply, done) => {
63+
const requestId = Number.parseInt(req.body.requestId)
64+
req.requestContext.set('preHandler', requestId)
65+
done()
66+
})
67+
68+
app.addHook('preSerialization', (req, reply, payload, done) => {
69+
const onRequestValue = req.requestContext.get('onRequest')
70+
const preValidationValue = req.requestContext.get('preValidation')
71+
done(null, {
72+
...payload,
73+
preSerialization1: onRequestValue,
74+
preSerialization2: preValidationValue,
75+
})
76+
})
77+
app.route({
78+
url: '/',
79+
method: ['GET', 'POST'],
80+
handler: endpoint,
81+
})
82+
83+
return app
84+
}
85+
86+
module.exports = {
87+
initAppPostWithAllPlugins,
88+
initAppPostWithPrevalidation,
89+
initAppPost,
90+
initAppGet,
91+
}

0 commit comments

Comments
 (0)