From a5cbaf265f028aadef24d518c89b9d41643d9f53 Mon Sep 17 00:00:00 2001 From: Igor Savin Date: Fri, 14 Aug 2020 17:34:49 +0200 Subject: [PATCH] Fix context loss when async storage is instantiated early in the request lifecycle (#12) --- .eslintrc.json | 2 +- README.md | 6 +- index.d.ts | 18 ++- index.test-d.ts | 4 + lib/requestContextPlugin.js | 22 ++- package.json | 1 + test/internal/appInitializer.js | 91 +++++++++++ test/requestContextPlugin.e2e.spec.js | 192 +++++++++++++++++++++++ test/requestContextPlugin.spec.js | 217 +++++++++++++++++--------- 9 files changed, 470 insertions(+), 83 deletions(-) create mode 100644 test/internal/appInitializer.js create mode 100644 test/requestContextPlugin.e2e.spec.js diff --git a/.eslintrc.json b/.eslintrc.json index c74eddd..d87010f 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -12,7 +12,7 @@ "prettier" ], "parserOptions": { - "ecmaVersion": 2015, + "ecmaVersion": 2018, "sourceType": "module" }, "rules": { diff --git a/README.md b/README.md index cbee564..4ae2526 100644 --- a/README.md +++ b/README.md @@ -27,15 +27,17 @@ const { fastifyRequestContextPlugin } = require('fastify-request-context') const fastify = require('fastify'); fastify.register(fastifyRequestContextPlugin, { + hook: 'preValidation', defaultStoreValues: { user: { id: 'system' } } }); ``` -This plugin accepts option named `defaultStoreValues`. +This plugin accepts options `hook` and `defaultStoreValues`. -`defaultStoreValues` set initial values for the store (that can be later overwritten during request execution if needed). This is an optional parameter. +* `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` sets initial values for the store (that can be later overwritten during request execution if needed). This is an optional parameter. From there you can set a context in another hook, route, or method that is within scope. diff --git a/index.d.ts b/index.d.ts index 4039372..dc42b2e 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,12 +1,28 @@ -import Fastify, { FastifyPlugin, FastifyRequest } from 'fastify' +import { FastifyPlugin, FastifyRequest } from 'fastify' export type RequestContext = { get: (key: string) => T | undefined set: (key: string, value: T) => void } +export type Hook = + | 'onRequest' + | 'preParsing' + | 'preValidation' + | 'preHandler' + | 'preSerialization' + | 'onSend' + | 'onResponse' + | 'onTimeout' + | 'onError' + | 'onRoute' + | 'onRegister' + | 'onReady' + | 'onClose' + export type RequestContextOptions = { defaultStoreValues?: Record + hook?: Hook } declare module 'fastify' { diff --git a/index.test-d.ts b/index.test-d.ts index 3c28ff0..56a060f 100644 --- a/index.test-d.ts +++ b/index.test-d.ts @@ -13,6 +13,10 @@ expectAssignable({}) expectAssignable({ defaultStoreValues: { a: 'dummy' }, }) +expectAssignable({ + hook: 'preValidation', + defaultStoreValues: { a: 'dummy' }, +}) expectType(app.requestContext) diff --git a/lib/requestContextPlugin.js b/lib/requestContextPlugin.js index 6a1ad07..523ef60 100644 --- a/lib/requestContextPlugin.js +++ b/lib/requestContextPlugin.js @@ -1,5 +1,7 @@ const fp = require('fastify-plugin') const { als } = require('asynchronous-local-storage') +const { AsyncResource } = require('async_hooks') +const asyncResourceSymbol = Symbol('asyncResource') const requestContext = { get: als.get, @@ -9,12 +11,28 @@ const requestContext = { function plugin(fastify, opts, next) { fastify.decorate('requestContext', requestContext) fastify.decorateRequest('requestContext', requestContext) + fastify.decorateRequest(asyncResourceSymbol, null) + const hook = opts.hook || 'onRequest' - fastify.addHook('onRequest', (req, res, done) => { + fastify.addHook(hook, (req, res, done) => { als.runWith(() => { - done() + const asyncResource = new AsyncResource('fastify-request-context') + req[asyncResourceSymbol] = asyncResource + asyncResource.runInAsyncScope(done, req.raw) }, opts.defaultStoreValues) }) + + // Both of onRequest and preParsing are executed after the als.runWith call within the "proper" async context (AsyncResource implicitly created by ALS). + // 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 + if (hook === 'onRequest' || hook === 'preParsing') { + fastify.addHook('preValidation', (req, res, done) => { + const asyncResource = req[asyncResourceSymbol] + asyncResource.runInAsyncScope(done, req.raw) + }) + } + next() } diff --git a/package.json b/package.json index 8421e1d..9e950d1 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "eslint-plugin-prettier": "^3.1.4", "jest": "^26.4.0", "prettier": "^2.0.5", + "superagent": "^6.0.0", "tsd": "^0.13.1", "typescript": "3.9.7" }, diff --git a/test/internal/appInitializer.js b/test/internal/appInitializer.js new file mode 100644 index 0000000..0eab6bf --- /dev/null +++ b/test/internal/appInitializer.js @@ -0,0 +1,91 @@ +const fastify = require('fastify') +const { fastifyRequestContextPlugin } = require('../../lib/requestContextPlugin') + +function initAppGet(endpoint) { + const app = fastify({ logger: true }) + app.register(fastifyRequestContextPlugin) + + app.get('/', endpoint) + return app +} + +function initAppPost(endpoint) { + const app = fastify({ logger: true }) + app.register(fastifyRequestContextPlugin) + + app.post('/', endpoint) + + return app +} + +function initAppPostWithPrevalidation(endpoint) { + const app = fastify({ logger: true }) + app.register(fastifyRequestContextPlugin, { hook: 'preValidation' }) + + const preValidationFn = (req, reply, done) => { + const requestId = Number.parseInt(req.body.requestId) + req.requestContext.set('testKey', `testValue${requestId}`) + done() + } + + app.route({ + url: '/', + method: ['GET', 'POST'], + preValidation: preValidationFn, + handler: endpoint, + }) + + return app +} + +function initAppPostWithAllPlugins(endpoint, requestHook) { + const app = fastify({ logger: true }) + app.register(fastifyRequestContextPlugin, { hook: requestHook }) + + app.addHook('onRequest', (req, reply, done) => { + req.requestContext.set('onRequest', 'dummy') + done() + }) + + app.addHook('preParsing', (req, reply, payload, done) => { + req.requestContext.set('preParsing', 'dummy') + done(null, payload) + }) + + app.addHook('preValidation', (req, reply, done) => { + const requestId = Number.parseInt(req.body.requestId) + req.requestContext.set('preValidation', requestId) + req.requestContext.set('testKey', `testValue${requestId}`) + done() + }) + + app.addHook('preHandler', (req, reply, done) => { + const requestId = Number.parseInt(req.body.requestId) + req.requestContext.set('preHandler', requestId) + done() + }) + + app.addHook('preSerialization', (req, reply, payload, done) => { + const onRequestValue = req.requestContext.get('onRequest') + const preValidationValue = req.requestContext.get('preValidation') + done(null, { + ...payload, + preSerialization1: onRequestValue, + preSerialization2: preValidationValue, + }) + }) + app.route({ + url: '/', + method: ['GET', 'POST'], + handler: endpoint, + }) + + return app +} + +module.exports = { + initAppPostWithAllPlugins, + initAppPostWithPrevalidation, + initAppPost, + initAppGet, +} diff --git a/test/requestContextPlugin.e2e.spec.js b/test/requestContextPlugin.e2e.spec.js new file mode 100644 index 0000000..199fcc0 --- /dev/null +++ b/test/requestContextPlugin.e2e.spec.js @@ -0,0 +1,192 @@ +const request = require('superagent') +const { + initAppPostWithPrevalidation, + initAppPostWithAllPlugins, +} = require('./internal/appInitializer') +const { TestService } = require('./internal/testService') + +describe('requestContextPlugin E2E', () => { + let app + afterEach(() => { + return app.close() + }) + + it('correctly preserves values set in prevalidation phase within single POST request', () => { + expect.assertions(2) + + let testService + let responseCounter = 0 + return new Promise((resolveResponsePromise) => { + const promiseRequest2 = new Promise((resolveRequest2Promise) => { + const promiseRequest1 = new Promise((resolveRequest1Promise) => { + const route = (req) => { + const requestId = req.requestContext.get('testKey') + + function prepareReply() { + return testService.processRequest(requestId.replace('testValue', '')).then(() => { + const storedValue = req.requestContext.get('testKey') + return Promise.resolve({ storedValue }) + }) + } + + // We don't want to read values until both requests wrote their values to see if there is a racing condition + if (requestId === 'testValue1') { + resolveRequest1Promise() + return promiseRequest2.then(prepareReply) + } + + if (requestId === 'testValue2') { + resolveRequest2Promise() + return promiseRequest1.then(prepareReply) + } + + throw new Error(`Unexpected requestId: ${requestId}`) + } + + app = initAppPostWithPrevalidation(route) + app.listen(0).then(() => { + testService = new TestService(app) + const { address, port } = app.server.address() + const url = `${address}:${port}` + const response1Promise = request('POST', url) + .send({ requestId: 1 }) + .then((response) => { + expect(response.body.storedValue).toBe('testValue1') + responseCounter++ + if (responseCounter === 2) { + resolveResponsePromise() + } + }) + + const response2Promise = request('POST', url) + .send({ requestId: 2 }) + .then((response) => { + expect(response.body.storedValue).toBe('testValue2') + responseCounter++ + if (responseCounter === 2) { + resolveResponsePromise() + } + }) + + return Promise.all([response1Promise, response2Promise]) + }) + }) + + return promiseRequest1 + }) + + return promiseRequest2 + }) + }) + + it('correctly preserves values set in multiple phases within single POST request', () => { + expect.assertions(10) + + let testService + let responseCounter = 0 + return new Promise((resolveResponsePromise) => { + const promiseRequest2 = new Promise((resolveRequest2Promise) => { + const promiseRequest1 = new Promise((resolveRequest1Promise) => { + const route = (req) => { + const onRequestValue = req.requestContext.get('onRequest') + const preParsingValue = req.requestContext.get('preParsing') + const preValidationValue = req.requestContext.get('preValidation') + const preHandlerValue = req.requestContext.get('preHandler') + + expect(onRequestValue).toBe(undefined) + expect(preParsingValue).toBe(undefined) + expect(preValidationValue).toEqual(expect.any(Number)) + expect(preHandlerValue).toEqual(expect.any(Number)) + + const requestId = `testValue${preHandlerValue}` + + function prepareReply() { + return testService.processRequest(requestId.replace('testValue', '')).then(() => { + const storedValue = req.requestContext.get('preValidation') + return Promise.resolve({ storedValue: `testValue${storedValue}` }) + }) + } + + // We don't want to read values until both requests wrote their values to see if there is a racing condition + if (requestId === 'testValue1') { + resolveRequest1Promise() + return promiseRequest2.then(prepareReply) + } + + if (requestId === 'testValue2') { + resolveRequest2Promise() + return promiseRequest1.then(prepareReply) + } + + throw new Error(`Unexpected requestId: ${requestId}`) + } + + app = initAppPostWithAllPlugins(route, 'preValidation') + + app.listen(0).then(() => { + testService = new TestService(app) + const { address, port } = app.server.address() + const url = `${address}:${port}` + const response1Promise = request('POST', url) + .send({ requestId: 1 }) + .then((response) => { + expect(response.body.storedValue).toBe('testValue1') + responseCounter++ + if (responseCounter === 2) { + resolveResponsePromise() + } + }) + + const response2Promise = request('POST', url) + .send({ requestId: 2 }) + .then((response) => { + expect(response.body.storedValue).toBe('testValue2') + responseCounter++ + if (responseCounter === 2) { + resolveResponsePromise() + } + }) + + return Promise.all([response1Promise, response2Promise]) + }) + }) + + return promiseRequest1 + }) + + return promiseRequest2 + }) + }) + + it('does not lose request context after body parsing', () => { + expect.assertions(7) + const route = (req) => { + const onRequestValue = req.requestContext.get('onRequest') + const preParsingValue = req.requestContext.get('preParsing') + const preValidationValue = req.requestContext.get('preValidation') + const preHandlerValue = req.requestContext.get('preHandler') + + expect(onRequestValue).toBe('dummy') + expect(preParsingValue).toBe('dummy') + expect(preValidationValue).toEqual(expect.any(Number)) + expect(preHandlerValue).toEqual(expect.any(Number)) + + const requestId = `testValue${preHandlerValue}` + return Promise.resolve({ storedValue: requestId }) + } + + app = initAppPostWithAllPlugins(route, 'onRequest') + + return app.listen(0).then(() => { + const { address, port } = app.server.address() + const url = `${address}:${port}` + return request('POST', url) + .send({ requestId: 1 }) + .then((response) => { + expect(response.body.storedValue).toBe('testValue1') + expect(response.body.preSerialization1).toBe('dummy') + expect(response.body.preSerialization2).toBe(1) + }) + }) + }) +}) diff --git a/test/requestContextPlugin.spec.js b/test/requestContextPlugin.spec.js index 0b79eb8..d9da3d9 100644 --- a/test/requestContextPlugin.spec.js +++ b/test/requestContextPlugin.spec.js @@ -1,25 +1,10 @@ -const fastify = require('fastify') -const { fastifyRequestContextPlugin } = require('../lib/requestContextPlugin') +const { + initAppPost, + initAppPostWithPrevalidation, + initAppGet, +} = require('./internal/appInitializer') const { TestService } = require('./internal/testService') -function initAppGet(endpoint) { - const app = fastify({ logger: true }) - app.register(fastifyRequestContextPlugin) - - app.get('/', endpoint) - - return app.ready() -} - -function initAppPost(endpoint) { - const app = fastify({ logger: true }) - app.register(fastifyRequestContextPlugin) - - app.post('/', endpoint) - - return app.ready() -} - describe('requestContextPlugin', () => { let app afterEach(() => { @@ -59,37 +44,39 @@ describe('requestContextPlugin', () => { } } - initAppGet(route).then((_app) => { - app = _app - testService = new TestService(app) - const response1Promise = app - .inject() - .get('/') - .query({ requestId: 1 }) - .end() - .then((response) => { - expect(response.json().storedValue).toBe('testValue1') - responseCounter++ - if (responseCounter === 2) { - resolveResponsePromise() - } - }) + initAppGet(route) + .ready() + .then((_app) => { + app = _app + testService = new TestService(app) + const response1Promise = app + .inject() + .get('/') + .query({ requestId: 1 }) + .end() + .then((response) => { + expect(response.json().storedValue).toBe('testValue1') + responseCounter++ + if (responseCounter === 2) { + resolveResponsePromise() + } + }) - const response2Promise = app - .inject() - .get('/') - .query({ requestId: 2 }) - .end() - .then((response) => { - expect(response.json().storedValue).toBe('testValue2') - responseCounter++ - if (responseCounter === 2) { - resolveResponsePromise() - } - }) + const response2Promise = app + .inject() + .get('/') + .query({ requestId: 2 }) + .end() + .then((response) => { + expect(response.json().storedValue).toBe('testValue2') + responseCounter++ + if (responseCounter === 2) { + resolveResponsePromise() + } + }) - return Promise.all([response1Promise, response2Promise]) - }) + return Promise.all([response1Promise, response2Promise]) + }) }) return promiseRequest1 @@ -132,37 +119,113 @@ describe('requestContextPlugin', () => { } } - initAppPost(route).then((_app) => { - app = _app - testService = new TestService(app) - const response1Promise = app - .inject() - .post('/') - .body({ requestId: 1 }) - .end() - .then((response) => { - expect(response.json().storedValue).toBe('testValue1') - responseCounter++ - if (responseCounter === 2) { - resolveResponsePromise() - } - }) + initAppPost(route) + .ready() + .then((_app) => { + app = _app + testService = new TestService(app) + const response1Promise = app + .inject() + .post('/') + .body({ requestId: 1 }) + .end() + .then((response) => { + expect(response.json().storedValue).toBe('testValue1') + responseCounter++ + if (responseCounter === 2) { + resolveResponsePromise() + } + }) + + const response2Promise = app + .inject() + .post('/') + .body({ requestId: 2 }) + .end() + .then((response) => { + expect(response.json().storedValue).toBe('testValue2') + responseCounter++ + if (responseCounter === 2) { + resolveResponsePromise() + } + }) + + return Promise.all([response1Promise, response2Promise]) + }) + }) + + return promiseRequest1 + }) + + return promiseRequest2 + }) + }) + + it('correctly preserves values set in prevalidation phase within single POST request', () => { + expect.assertions(2) + + let testService + let responseCounter = 0 + return new Promise((resolveResponsePromise) => { + const promiseRequest2 = new Promise((resolveRequest2Promise) => { + const promiseRequest1 = new Promise((resolveRequest1Promise) => { + const route = (req, reply) => { + const requestId = req.requestContext.get('testKey') - const response2Promise = app - .inject() - .post('/') - .body({ requestId: 2 }) - .end() - .then((response) => { - expect(response.json().storedValue).toBe('testValue2') - responseCounter++ - if (responseCounter === 2) { - resolveResponsePromise() - } + function prepareReply() { + testService.processRequest(requestId.replace('testValue', '')).then(() => { + const storedValue = req.requestContext.get('testKey') + reply.status(204).send({ + storedValue, + }) }) + } + + // We don't want to read values until both requests wrote their values to see if there is a racing condition + if (requestId === 'testValue1') { + resolveRequest1Promise() + return promiseRequest2.then(prepareReply) + } + + if (requestId === 'testValue2') { + resolveRequest2Promise() + return promiseRequest1.then(prepareReply) + } + } + + initAppPostWithPrevalidation(route) + .ready() + .then((_app) => { + app = _app + testService = new TestService(app) + const response1Promise = app + .inject() + .post('/') + .body({ requestId: 1 }) + .end() + .then((response) => { + expect(response.json().storedValue).toBe('testValue1') + responseCounter++ + if (responseCounter === 2) { + resolveResponsePromise() + } + }) + + const response2Promise = app + .inject() + .post('/') + .body({ requestId: 2 }) + .end() + .then((response) => { + expect(response.json().storedValue).toBe('testValue2') + responseCounter++ + if (responseCounter === 2) { + resolveResponsePromise() + } + }) - return Promise.all([response1Promise, response2Promise]) - }) + return Promise.all([response1Promise, response2Promise]) + }) }) return promiseRequest1