├── .gitattributes ├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── .npmignore ├── .npmrc ├── .prettierrc.json ├── .taprc ├── LICENSE ├── README.md ├── eslint.config.js ├── index.js ├── package.json ├── test-tap └── requestContextPlugin.e2e.test.js ├── test ├── internal │ ├── appInitializer.js │ ├── testService.js │ └── watcherService.js ├── requestContextPlugin.e2e.spec.js └── requestContextPlugin.spec.js ├── tsconfig.json └── types ├── index.d.ts └── index.test-d.ts /.gitattributes: -------------------------------------------------------------------------------- 1 | # Set default behavior to automatically convert line endings 2 | * text=auto eol=lf 3 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | open-pull-requests-limit: 10 8 | 9 | - package-ecosystem: "npm" 10 | directory: "/" 11 | schedule: 12 | interval: "monthly" 13 | open-pull-requests-limit: 10 14 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - next 8 | - 'v*' 9 | paths-ignore: 10 | - 'docs/**' 11 | - '*.md' 12 | pull_request: 13 | paths-ignore: 14 | - 'docs/**' 15 | - '*.md' 16 | 17 | permissions: 18 | contents: read 19 | 20 | jobs: 21 | test: 22 | permissions: 23 | contents: write 24 | pull-requests: write 25 | uses: fastify/workflows/.github/workflows/plugins-ci.yml@v5 26 | with: 27 | license-check: true 28 | lint: true -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | 132 | # Vim swap files 133 | *.swp 134 | 135 | # macOS files 136 | .DS_Store 137 | 138 | # Clinic 139 | .clinic 140 | 141 | # lock files 142 | bun.lockb 143 | package-lock.json 144 | pnpm-lock.yaml 145 | yarn.lock 146 | 147 | # editor files 148 | .vscode 149 | .idea 150 | 151 | #tap files 152 | .tap/ 153 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .npmignore 3 | node_modules/ 4 | README.md 5 | test/ 6 | .nyc_output/ 7 | coverage/ 8 | lib/ 9 | jest.config.json 10 | package-lock.json 11 | tsconfig.json 12 | .eslintrc.json 13 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "singleQuote": true, 4 | "bracketSpacing": true, 5 | "semi": false, 6 | "arrowParens": "always" 7 | } 8 | -------------------------------------------------------------------------------- /.taprc: -------------------------------------------------------------------------------- 1 | files: 2 | - test-tap/**/*.test.js 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Igor Savin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @fastify/request-context 2 | 3 | [![NPM Version][npm-image]][npm-url] 4 | [![NPM Downloads][downloads-image]][downloads-url] 5 | [![CI](https://github.com/fastify/fastify-request-context/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/fastify/fastify-request-context/actions/workflows/ci.yml) 6 | 7 | Request-scoped storage support, based on [AsyncLocalStorage](https://nodejs.org/api/async_context.html#asynchronous-context-tracking). 8 | 9 | Inspired by work done in [fastify-http-context](https://github.com/thorough-developer/fastify-http-context). 10 | 11 | This plugin introduces thread-local request-scoped HTTP context, where any variables set within the scope of a single HTTP call will not be overwritten by simultaneous calls to the API 12 | nor will variables remain available once a request is completed. 13 | 14 | Frequent use-cases are persisting request-aware logger instances and user authorization information. 15 | 16 | 17 | 18 | ## Install 19 | ``` 20 | npm i @fastify/request-context 21 | ``` 22 | 23 | ### Compatibility 24 | | Plugin version | Fastify version | 25 | | ---------------|-----------------| 26 | | `>=6.x` | `^5.x` | 27 | | `>=4.x <6.x` | `^4.x` | 28 | | `>=2.x <4.x` | `^3.x` | 29 | | `^1.x` | `^2.x` | 30 | | `^1.x` | `^1.x` | 31 | 32 | 33 | Please note that if a Fastify version is out of support, then so are the corresponding versions of this plugin 34 | in the table above. 35 | See [Fastify's LTS policy](https://github.com/fastify/fastify/blob/main/docs/Reference/LTS.md) for more details. 36 | 37 | ## Getting started 38 | 39 | Set up the plugin: 40 | 41 | ```js 42 | const { fastifyRequestContext } = require('@fastify/request-context') 43 | const fastify = require('fastify'); 44 | 45 | fastify.register(fastifyRequestContext); 46 | ``` 47 | 48 | Or customize hook and default store values: 49 | 50 | ```js 51 | const { fastifyRequestContext } = require('@fastify/request-context') 52 | const fastify = require('fastify'); 53 | 54 | fastify.register(fastifyRequestContext, { 55 | hook: 'preValidation', 56 | defaultStoreValues: { 57 | user: { id: 'system' } 58 | } 59 | }); 60 | ``` 61 | 62 | Default store values can be set through a function as well: 63 | 64 | ```js 65 | const { fastifyRequestContext } = require('@fastify/request-context') 66 | const fastify = require('fastify'); 67 | 68 | fastify.register(fastifyRequestContext, { 69 | defaultStoreValues: request => ({ 70 | log: request.log.child({ foo: 123 }) 71 | }) 72 | }); 73 | ``` 74 | 75 | This plugin accepts options `hook` and `defaultStoreValues`, `createAsyncResource`. 76 | 77 | * `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`. 78 | * `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. 79 | * `createAsyncResource` can specify a factory function that creates an extended `AsyncResource` object. 80 | 81 | From there you can set a context in another hook, route, or method that is within scope. 82 | 83 | Request context (with methods `get` and `set`) is exposed by library itself, but is also available as decorator on `fastify.requestContext` app instance as well as on `req` request instance. 84 | 85 | For instance: 86 | 87 | ```js 88 | const { fastifyRequestContext, requestContext } = require('@fastify/request-context') 89 | const fastify = require('fastify'); 90 | 91 | const app = fastify({ logger: true }) 92 | app.register(fastifyRequestContext, { 93 | defaultStoreValues: { 94 | user: { id: 'system' } 95 | }, 96 | createAsyncResource: (req, context) => new MyCustomAsyncResource('custom-resource-type', req.id, context.user.id) 97 | }); 98 | 99 | app.addHook('onRequest', (req, reply, done) => { 100 | // Overwrite the defaults. 101 | // This is completely equivalent to using app.requestContext or just requestContext 102 | req.requestContext.set('user', { id: 'helloUser' }); 103 | done(); 104 | }); 105 | 106 | // this should now get `helloUser` instead of the default `system` 107 | app.get('/', (req, reply) => { 108 | // requestContext singleton exposed by the library retains same request-scoped values that were set using `req.requestContext` 109 | const user = requestContext.get('user'); 110 | 111 | // read the whole store 112 | const store = req.requestContext.getStore(); 113 | reply.code(200).send( { store }); 114 | }); 115 | 116 | app.get('/decorator', function (req, reply) { 117 | // requestContext singleton exposed as decorator in the fastify instance and can be retrieved: 118 | const user = this.requestContext.get('user'); // using `this` thanks to the handler function binding 119 | const theSameUser = app.requestContext.get('user'); // directly using the `app` instance 120 | reply.code(200).send( { user }); 121 | }); 122 | 123 | app.listen({ port: 3000 }, (err, address) => { 124 | if (err) throw err 125 | app.log.info(`server listening on ${address}`) 126 | }); 127 | 128 | return app.ready() 129 | ``` 130 | 131 | ## TypeScript 132 | 133 | In TypeScript you are expected to augment the module to type your context: 134 | 135 | ```ts 136 | import {requestContext} from '@fastify/request-context' 137 | 138 | declare module '@fastify/request-context' { 139 | interface RequestContextData { 140 | foo: string 141 | } 142 | } 143 | 144 | // Type is "string" (if "strictNullChecks: true" in your tsconfig it will be "string | undefined") 145 | const foo = requestContext.get('foo') 146 | // Causes a type violation as 'bar' is not a key on RequestContextData 147 | const bar = requestContext.get('bar') 148 | ``` 149 | 150 | If you have `"strictNullChecks": true` (or have `"strict": true`, which sets `"strictNullChecks": true`) in your TypeScript configuration, you will notice that the type of the returned value can still be `undefined` even though the `RequestContextData` interface has a specific type. For a discussion about how to work around this and the pros/cons of doing so, please read [this issue (#93)](https://github.com/fastify/fastify-request-context/issues/93). 151 | 152 | ## Usage outside of a request 153 | 154 | If functions depend on requestContext but are not called in a request, i.e. in tests or workers, they can be wrapped in the asyncLocalStorage instance of requestContext: 155 | 156 | ``` 157 | import { asyncLocalStorage } from '@fastify/request-context'; 158 | 159 | it('should set request context', () => { 160 | asyncLocalStorage.run({}, async () => { 161 | requestContext.set('userId', 'some-fake-user-id'); 162 | someCodeThatUsesRequestContext(); // requestContext.get('userId') will work 163 | }) 164 | }) 165 | ``` 166 | 167 | ## License 168 | 169 | Licensed under [MIT](./LICENSE). 170 | 171 | [npm-image]: https://img.shields.io/npm/v/@fastify/request-context.svg 172 | [npm-url]: https://npmjs.com/package/@fastify/request-context 173 | [downloads-image]: https://img.shields.io/npm/dm/fastify-request-context.svg 174 | [downloads-url]: https://npmjs.org/package/@fastify/request-context 175 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const globals = require('globals') 4 | const js = require('@eslint/js') 5 | const prettier = require('eslint-plugin-prettier/recommended') 6 | 7 | module.exports = [ 8 | { 9 | languageOptions: { 10 | globals: { 11 | ...globals.node, 12 | ...globals.jest, 13 | }, 14 | }, 15 | }, 16 | js.configs.recommended, 17 | prettier, 18 | ] 19 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { AsyncLocalStorage, AsyncResource } = require('node:async_hooks') 4 | 5 | const fp = require('fastify-plugin') 6 | 7 | const asyncResourceSymbol = Symbol('asyncResource') 8 | 9 | const asyncLocalStorage = new AsyncLocalStorage() 10 | 11 | const requestContext = { 12 | get: (key) => { 13 | const store = asyncLocalStorage.getStore() 14 | return store ? store[key] : undefined 15 | }, 16 | set: (key, value) => { 17 | const store = asyncLocalStorage.getStore() 18 | if (store) { 19 | store[key] = value 20 | } 21 | }, 22 | getStore: () => { 23 | return asyncLocalStorage.getStore() 24 | }, 25 | } 26 | 27 | function fastifyRequestContext(fastify, opts, next) { 28 | fastify.decorate('requestContext', requestContext) 29 | fastify.decorateRequest('requestContext', { getter: () => requestContext }) 30 | fastify.decorateRequest(asyncResourceSymbol, null) 31 | const hook = opts.hook || 'onRequest' 32 | const hasDefaultStoreValuesFactory = typeof opts.defaultStoreValues === 'function' 33 | 34 | fastify.addHook(hook, (req, _res, done) => { 35 | const defaultStoreValues = hasDefaultStoreValuesFactory 36 | ? opts.defaultStoreValues(req) 37 | : opts.defaultStoreValues 38 | 39 | asyncLocalStorage.run({ ...defaultStoreValues }, () => { 40 | const asyncResource = 41 | opts.createAsyncResource != null 42 | ? opts.createAsyncResource(req, requestContext) 43 | : new AsyncResource('fastify-request-context') 44 | req[asyncResourceSymbol] = asyncResource 45 | asyncResource.runInAsyncScope(done, req.raw) 46 | }) 47 | }) 48 | 49 | // Both of onRequest and preParsing are executed after the als.runWith call within the "proper" async context (AsyncResource implicitly created by ALS). 50 | // However, preValidation, preHandler and the route handler are executed as a part of req.emit('end') call which happens 51 | // in a different async context, as req/res may emit events in a different context. 52 | // Related to https://github.com/nodejs/node/issues/34430 and https://github.com/nodejs/node/issues/33723 53 | if (hook === 'onRequest' || hook === 'preParsing') { 54 | fastify.addHook('preValidation', (req, _res, done) => { 55 | const asyncResource = req[asyncResourceSymbol] 56 | asyncResource.runInAsyncScope(done, req.raw) 57 | }) 58 | } 59 | 60 | next() 61 | } 62 | 63 | module.exports = fp(fastifyRequestContext, { 64 | fastify: '5.x', 65 | name: '@fastify/request-context', 66 | }) 67 | module.exports.default = fastifyRequestContext 68 | module.exports.fastifyRequestContext = fastifyRequestContext 69 | module.exports.asyncLocalStorage = asyncLocalStorage 70 | module.exports.requestContext = requestContext 71 | 72 | // Deprecated 73 | module.exports.fastifyRequestContextPlugin = fastifyRequestContext 74 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fastify/request-context", 3 | "version": "6.2.0", 4 | "description": "Request-scoped storage support, based on Asynchronous Local Storage, with fallback to cls-hooked for older Node versions", 5 | "license": "MIT", 6 | "maintainers": [ 7 | { 8 | "name": "Igor Savin", 9 | "email": "kibertoad@gmail.com" 10 | } 11 | ], 12 | "main": "index.js", 13 | "type": "commonjs", 14 | "types": "types/index.d.ts", 15 | "scripts": { 16 | "test": "npm run test:unit && npm run test:typescript", 17 | "test:unit": "c8 --100 node --test", 18 | "test:typescript": "tsd", 19 | "lint": "eslint \"test/**/*.js\" \"test-tap/**/*.js\" index.js", 20 | "prettier": "prettier --write \"{lib,test,test-tap}/**/*.js\" index.js \"types/**/*.ts\"" 21 | }, 22 | "dependencies": { 23 | "fastify-plugin": "^5.0.0" 24 | }, 25 | "devDependencies": { 26 | "@fastify/pre-commit": "^2.1.0", 27 | "@types/node": "^22.0.0", 28 | "c8": "^10.1.3", 29 | "eslint": "^9.6.0", 30 | "eslint-config-prettier": "^10.0.1", 31 | "eslint-plugin-prettier": "^5.1.3", 32 | "fastify": "^5.0.0", 33 | "prettier": "^3.2.5", 34 | "superagent": "^10.0.0", 35 | "tsd": "^0.32.0" 36 | }, 37 | "homepage": "http://github.com/fastify/fastify-request-context", 38 | "funding": [ 39 | { 40 | "type": "github", 41 | "url": "https://github.com/sponsors/fastify" 42 | }, 43 | { 44 | "type": "opencollective", 45 | "url": "https://opencollective.com/fastify" 46 | } 47 | ], 48 | "repository": { 49 | "type": "git", 50 | "url": "git://github.com/fastify/fastify-request-context.git" 51 | }, 52 | "bugs": { 53 | "url": "https://github.com/fastify/fastify-request-context/issues" 54 | }, 55 | "keywords": [ 56 | "fastify", 57 | "plugin", 58 | "request", 59 | "context", 60 | "http-context", 61 | "request-context", 62 | "fastify-http-context", 63 | "fastify-request-context", 64 | "asynchronouslocalstorage", 65 | "asynchronous-local-storage" 66 | ], 67 | "files": [ 68 | "README.md", 69 | "LICENSE", 70 | "lib/*", 71 | "index.js", 72 | "types/index.d.ts" 73 | ], 74 | "publishConfig": { 75 | "access": "public" 76 | }, 77 | "pre-commit": [ 78 | "lint", 79 | "test" 80 | ] 81 | } 82 | -------------------------------------------------------------------------------- /test-tap/requestContextPlugin.e2e.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const fastify = require('fastify') 4 | const request = require('superagent') 5 | const { 6 | initAppPostWithPrevalidation, 7 | initAppPostWithAllPlugins, 8 | initAppGetWithDefaultStoreValues, 9 | } = require('../test/internal/appInitializer') 10 | const { fastifyRequestContext } = require('..') 11 | const { TestService } = require('../test/internal/testService') 12 | const { test, afterEach } = require('node:test') 13 | const { CustomResource, AsyncHookContainer } = require('../test/internal/watcherService') 14 | const { executionAsyncId } = require('node:async_hooks') 15 | 16 | let app 17 | afterEach(() => { 18 | return app.close() 19 | }) 20 | 21 | test('correctly preserves values set in prevalidation phase within single POST request', (t) => { 22 | t.plan(2) 23 | 24 | let testService 25 | let responseCounter = 0 26 | return new Promise((resolveResponsePromise) => { 27 | const promiseRequest2 = new Promise((resolveRequest2Promise) => { 28 | const promiseRequest1 = new Promise((resolveRequest1Promise) => { 29 | const route = (req) => { 30 | const requestId = req.requestContext.get('testKey') 31 | 32 | function prepareReply() { 33 | return testService.processRequest(requestId.replace('testValue', '')).then(() => { 34 | const storedValue = req.requestContext.get('testKey') 35 | return Promise.resolve({ storedValue }) 36 | }) 37 | } 38 | 39 | // We don't want to read values until both requests wrote their values to see if there is a racing condition 40 | if (requestId === 'testValue1') { 41 | resolveRequest1Promise() 42 | return promiseRequest2.then(prepareReply) 43 | } 44 | 45 | if (requestId === 'testValue2') { 46 | resolveRequest2Promise() 47 | return promiseRequest1.then(prepareReply) 48 | } 49 | 50 | throw new Error(`Unexpected requestId: ${requestId}`) 51 | } 52 | 53 | app = initAppPostWithPrevalidation(route) 54 | app.listen({ port: 0, host: '127.0.0.1' }).then(() => { 55 | testService = new TestService(app) 56 | const { address, port } = app.server.address() 57 | const url = `http://${address}:${port}` 58 | const response1Promise = request('POST', url) 59 | .send({ requestId: 1 }) 60 | .then((response) => { 61 | t.assert.strictEqual(response.body.storedValue, 'testValue1') 62 | responseCounter++ 63 | if (responseCounter === 2) { 64 | resolveResponsePromise() 65 | } 66 | }) 67 | 68 | const response2Promise = request('POST', url) 69 | .send({ requestId: 2 }) 70 | .then((response) => { 71 | t.assert.strictEqual(response.body.storedValue, 'testValue2') 72 | responseCounter++ 73 | if (responseCounter === 2) { 74 | resolveResponsePromise() 75 | } 76 | }) 77 | 78 | return Promise.all([response1Promise, response2Promise]) 79 | }) 80 | }) 81 | 82 | return promiseRequest1 83 | }) 84 | 85 | return promiseRequest2 86 | }) 87 | }) 88 | 89 | test('correctly preserves values set in multiple phases within single POST request', (t) => { 90 | t.plan(10) 91 | 92 | let testService 93 | let responseCounter = 0 94 | return new Promise((resolveResponsePromise) => { 95 | const promiseRequest2 = new Promise((resolveRequest2Promise) => { 96 | const promiseRequest1 = new Promise((resolveRequest1Promise) => { 97 | const route = (req) => { 98 | const onRequestValue = req.requestContext.get('onRequest') 99 | const preParsingValue = req.requestContext.get('preParsing') 100 | const preValidationValue = req.requestContext.get('preValidation') 101 | const preHandlerValue = req.requestContext.get('preHandler') 102 | 103 | t.assert.strictEqual(onRequestValue, undefined) 104 | t.assert.strictEqual(preParsingValue, undefined) 105 | t.assert.ok(typeof preValidationValue === 'number') 106 | t.assert.ok(typeof preHandlerValue === 'number') 107 | 108 | const requestId = `testValue${preHandlerValue}` 109 | 110 | function prepareReply() { 111 | return testService.processRequest(requestId.replace('testValue', '')).then(() => { 112 | const storedValue = req.requestContext.get('preValidation') 113 | return Promise.resolve({ storedValue: `testValue${storedValue}` }) 114 | }) 115 | } 116 | 117 | // We don't want to read values until both requests wrote their values to see if there is a racing condition 118 | if (requestId === 'testValue1') { 119 | resolveRequest1Promise() 120 | return promiseRequest2.then(prepareReply) 121 | } 122 | 123 | if (requestId === 'testValue2') { 124 | resolveRequest2Promise() 125 | return promiseRequest1.then(prepareReply) 126 | } 127 | 128 | throw new Error(`Unexpected requestId: ${requestId}`) 129 | } 130 | 131 | app = initAppPostWithAllPlugins(route, 'preValidation') 132 | 133 | app.listen({ port: 0, host: '127.0.0.1' }).then(() => { 134 | testService = new TestService(app) 135 | const { address, port } = app.server.address() 136 | const url = `http://${address}:${port}` 137 | const response1Promise = request('POST', url) 138 | .send({ requestId: 1 }) 139 | .then((response) => { 140 | t.assert.strictEqual(response.body.storedValue, 'testValue1') 141 | responseCounter++ 142 | if (responseCounter === 2) { 143 | resolveResponsePromise() 144 | } 145 | }) 146 | 147 | const response2Promise = request('POST', url) 148 | .send({ requestId: 2 }) 149 | .then((response) => { 150 | t.assert.strictEqual(response.body.storedValue, 'testValue2') 151 | responseCounter++ 152 | if (responseCounter === 2) { 153 | resolveResponsePromise() 154 | } 155 | }) 156 | 157 | return Promise.all([response1Promise, response2Promise]) 158 | }) 159 | }) 160 | 161 | return promiseRequest1 162 | }) 163 | 164 | return promiseRequest2 165 | }) 166 | }) 167 | 168 | test('correctly preserves values set in multiple phases within single POST request', (t) => { 169 | t.plan(7) 170 | 171 | const route = (req) => { 172 | const onRequestValue = req.requestContext.get('onRequest') 173 | const preParsingValue = req.requestContext.get('preParsing') 174 | const preValidationValue = req.requestContext.get('preValidation') 175 | const preHandlerValue = req.requestContext.get('preHandler') 176 | 177 | t.assert.strictEqual(onRequestValue, 'dummy') 178 | t.assert.strictEqual(preParsingValue, 'dummy') 179 | t.assert.ok(typeof preValidationValue === 'number') 180 | t.assert.ok(typeof preHandlerValue === 'number') 181 | 182 | const requestId = `testValue${preHandlerValue}` 183 | return Promise.resolve({ storedValue: requestId }) 184 | } 185 | 186 | app = initAppPostWithAllPlugins(route) 187 | 188 | return app.listen({ port: 0, host: '127.0.0.1' }).then(() => { 189 | const { address, port } = app.server.address() 190 | const url = `http://${address}:${port}` 191 | return request('POST', url) 192 | .send({ requestId: 1 }) 193 | .then((response) => { 194 | t.assert.strictEqual(response.body.storedValue, 'testValue1') 195 | t.assert.strictEqual(response.body.preSerialization1, 'dummy') 196 | t.assert.strictEqual(response.body.preSerialization2, 1) 197 | }) 198 | }) 199 | }) 200 | 201 | test('does not affect new request context when mutating context data using no default values object', (t) => { 202 | t.plan(2) 203 | 204 | const route = (req) => { 205 | const { action } = req.query 206 | if (action === 'setvalue') { 207 | req.requestContext.set('foo', 'abc') 208 | } 209 | 210 | return Promise.resolve({ userId: req.requestContext.get('foo') }) 211 | } 212 | 213 | app = initAppGetWithDefaultStoreValues(route, undefined) 214 | 215 | return app.listen({ port: 0, host: '127.0.0.1' }).then(() => { 216 | const { address, port } = app.server.address() 217 | const url = `${address}:${port}` 218 | 219 | return request('GET', url) 220 | .query({ action: 'setvalue' }) 221 | .then((response1) => { 222 | t.assert.strictEqual(response1.body.userId, 'abc') 223 | 224 | return request('GET', url).then((response2) => { 225 | t.assert.ok(!response2.body.userId) 226 | }) 227 | }) 228 | }) 229 | }) 230 | 231 | test('does not affect new request context when mutating context data using default values object', (t) => { 232 | t.plan(2) 233 | 234 | const route = (req) => { 235 | const { action } = req.query 236 | if (action === 'setvalue') { 237 | req.requestContext.set('foo', 'abc') 238 | } 239 | 240 | return Promise.resolve({ userId: req.requestContext.get('foo') }) 241 | } 242 | 243 | app = initAppGetWithDefaultStoreValues(route, { 244 | foo: 'bar', 245 | }) 246 | 247 | return app.listen({ port: 0, host: '127.0.0.1' }).then(() => { 248 | const { address, port } = app.server.address() 249 | const url = `${address}:${port}` 250 | 251 | return request('GET', url) 252 | .query({ action: 'setvalue' }) 253 | .then((response1) => { 254 | t.assert.strictEqual(response1.body.userId, 'abc') 255 | 256 | return request('GET', url).then((response2) => { 257 | t.assert.strictEqual(response2.body.userId, 'bar') 258 | }) 259 | }) 260 | }) 261 | }) 262 | 263 | test('does not affect new request context when mutating context data using default values factory', (t) => { 264 | t.plan(2) 265 | 266 | const route = (req) => { 267 | const { action } = req.query 268 | if (action === 'setvalue') { 269 | req.requestContext.get('user').id = 'bob' 270 | } 271 | 272 | return Promise.resolve({ userId: req.requestContext.get('user').id }) 273 | } 274 | 275 | app = initAppGetWithDefaultStoreValues(route, () => ({ 276 | user: { id: 'system' }, 277 | })) 278 | 279 | return app.listen({ port: 0, host: '127.0.0.1' }).then(() => { 280 | const { address, port } = app.server.address() 281 | const url = `${address}:${port}` 282 | 283 | return request('GET', url) 284 | .query({ action: 'setvalue' }) 285 | .then((response1) => { 286 | t.assert.strictEqual(response1.body.userId, 'bob') 287 | 288 | return request('GET', url).then((response2) => { 289 | t.assert.strictEqual(response2.body.userId, 'system') 290 | }) 291 | }) 292 | }) 293 | }) 294 | 295 | test('ensure request instance is properly exposed to default values factory', (t) => { 296 | t.plan(1) 297 | 298 | const route = (req) => { 299 | return Promise.resolve({ userId: req.requestContext.get('user').id }) 300 | } 301 | 302 | app = initAppGetWithDefaultStoreValues(route, (req) => ({ 303 | user: { id: req.protocol }, 304 | })) 305 | 306 | return app.listen({ port: 0, host: '127.0.0.1' }).then(() => { 307 | const { address, port } = app.server.address() 308 | const url = `${address}:${port}` 309 | 310 | return request('GET', url).then((response1) => { 311 | t.assert.strictEqual(response1.body.userId, 'http') 312 | }) 313 | }) 314 | }) 315 | 316 | test('does not throw when accessing context object outside of context', (t) => { 317 | t.plan(2) 318 | 319 | const route = (req) => { 320 | return Promise.resolve({ userId: req.requestContext.get('user').id }) 321 | } 322 | 323 | app = initAppGetWithDefaultStoreValues(route, { 324 | user: { id: 'system' }, 325 | }) 326 | 327 | return app.listen({ port: 0, host: '127.0.0.1' }).then(() => { 328 | const { address, port } = app.server.address() 329 | const url = `${address}:${port}` 330 | 331 | t.assert.strictEqual(app.requestContext.get('user'), undefined) 332 | 333 | return request('GET', url).then((response1) => { 334 | t.assert.strictEqual(response1.body.userId, 'system') 335 | }) 336 | }) 337 | }) 338 | 339 | test('passing a custom resource factory function when create as AsyncResource', (t) => { 340 | t.plan(2) 341 | 342 | const container = new AsyncHookContainer(['fastify-request-context', 'custom-resource-type']) 343 | 344 | app = fastify({ logger: true }) 345 | app.register(fastifyRequestContext, { 346 | defaultStoreValues: { user: { id: 'system' } }, 347 | createAsyncResource: () => { 348 | return new CustomResource('custom-resource-type', '1111-2222-3333') 349 | }, 350 | }) 351 | 352 | const route = (req) => { 353 | const store = container.getStore(executionAsyncId()) 354 | t.assert.strictEqual(store.traceId, '1111-2222-3333') 355 | return Promise.resolve({ userId: req.requestContext.get('user').id }) 356 | } 357 | 358 | app.get('/', route) 359 | 360 | return app.listen({ port: 0, host: '127.0.0.1' }).then(() => { 361 | const { address, port } = app.server.address() 362 | const url = `${address}:${port}` 363 | 364 | return request('GET', url).then((response1) => { 365 | t.assert.strictEqual(response1.body.userId, 'system') 366 | }) 367 | }) 368 | }) 369 | 370 | test('returns the store', (t) => { 371 | t.plan(2) 372 | 373 | app = fastify({ logger: true }) 374 | app.register(fastifyRequestContext, { 375 | defaultStoreValues: { foo: 42 }, 376 | }) 377 | 378 | const route = (req) => { 379 | const store = req.requestContext.getStore() 380 | t.assert.strictEqual(store.foo, 42) 381 | return store.foo 382 | } 383 | 384 | app.get('/', route) 385 | 386 | return app.listen({ port: 0, host: '127.0.0.1' }).then(() => { 387 | const { address, port } = app.server.address() 388 | const url = `${address}:${port}` 389 | 390 | return request('GET', url).then((response1) => { 391 | t.assert.strictEqual(response1.body, 42) 392 | }) 393 | }) 394 | }) 395 | -------------------------------------------------------------------------------- /test/internal/appInitializer.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const fastify = require('fastify') 4 | const { fastifyRequestContext } = require('../..') 5 | 6 | function initAppGet(endpoint) { 7 | const app = fastify({ logger: true }) 8 | app.register(fastifyRequestContext) 9 | 10 | app.get('/', endpoint) 11 | return app 12 | } 13 | 14 | function initAppPost(endpoint) { 15 | const app = fastify({ logger: true }) 16 | app.register(fastifyRequestContext) 17 | 18 | app.post('/', endpoint) 19 | 20 | return app 21 | } 22 | 23 | function initAppPostWithPrevalidation(endpoint) { 24 | const app = fastify({ logger: true }) 25 | app.register(fastifyRequestContext, { hook: 'preValidation' }) 26 | 27 | const preValidationFn = (req, _reply, done) => { 28 | const requestId = Number.parseInt(req.body.requestId) 29 | req.requestContext.set('testKey', `testValue${requestId}`) 30 | done() 31 | } 32 | 33 | app.route({ 34 | url: '/', 35 | method: ['GET', 'POST'], 36 | preValidation: preValidationFn, 37 | handler: endpoint, 38 | }) 39 | 40 | return app 41 | } 42 | 43 | function initAppPostWithAllPlugins(endpoint, requestHook) { 44 | const app = fastify({ logger: true }) 45 | app.register(fastifyRequestContext, { hook: requestHook }) 46 | 47 | app.addHook('onRequest', (req, _reply, done) => { 48 | req.requestContext.set('onRequest', 'dummy') 49 | done() 50 | }) 51 | 52 | app.addHook('preParsing', (req, _reply, payload, done) => { 53 | req.requestContext.set('preParsing', 'dummy') 54 | done(null, payload) 55 | }) 56 | 57 | app.addHook('preValidation', (req, _reply, done) => { 58 | const requestId = Number.parseInt(req.body.requestId) 59 | req.requestContext.set('preValidation', requestId) 60 | req.requestContext.set('testKey', `testValue${requestId}`) 61 | done() 62 | }) 63 | 64 | app.addHook('preHandler', (req, _reply, done) => { 65 | const requestId = Number.parseInt(req.body.requestId) 66 | req.requestContext.set('preHandler', requestId) 67 | done() 68 | }) 69 | 70 | app.addHook('preSerialization', (req, _reply, payload, done) => { 71 | const onRequestValue = req.requestContext.get('onRequest') 72 | const preValidationValue = req.requestContext.get('preValidation') 73 | done(null, { 74 | ...payload, 75 | preSerialization1: onRequestValue, 76 | preSerialization2: preValidationValue, 77 | }) 78 | }) 79 | app.route({ 80 | url: '/', 81 | method: ['GET', 'POST'], 82 | handler: endpoint, 83 | }) 84 | 85 | return app 86 | } 87 | 88 | function initAppGetWithDefaultStoreValues(endpoint, defaultStoreValues) { 89 | const app = fastify({ logger: true }) 90 | app.register(fastifyRequestContext, { 91 | defaultStoreValues, 92 | }) 93 | 94 | app.get('/', endpoint) 95 | return app 96 | } 97 | 98 | module.exports = { 99 | initAppPostWithAllPlugins, 100 | initAppPostWithPrevalidation, 101 | initAppPost, 102 | initAppGet, 103 | initAppGetWithDefaultStoreValues, 104 | } 105 | -------------------------------------------------------------------------------- /test/internal/testService.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { requestContext } = require('../..') 4 | 5 | // Test class to check if nested calls with promises work correctly with async local storage 6 | class TestService { 7 | constructor(fastify) { 8 | this.appRequestContext = fastify.requestContext 9 | } 10 | 11 | processRequest(requestId) { 12 | return this.fetchData().then(() => { 13 | const testValueFromApp = this.appRequestContext.get('testKey') 14 | const testValueFromLib = requestContext.get('testKey') 15 | if (testValueFromApp !== `testValue${requestId}`) { 16 | throw new Error( 17 | `Wrong value retrieved from app context for request ${requestId}: ${testValueFromApp}`, 18 | ) 19 | } 20 | 21 | if (testValueFromLib !== `testValue${requestId}`) { 22 | throw new Error( 23 | `Wrong value retrieved from lib context for request ${requestId}: ${testValueFromLib}`, 24 | ) 25 | } 26 | }) 27 | } 28 | 29 | fetchData() { 30 | return new Promise((resolve) => { 31 | setTimeout(resolve, 10) 32 | }) 33 | } 34 | } 35 | 36 | module.exports = { 37 | TestService, 38 | } 39 | -------------------------------------------------------------------------------- /test/internal/watcherService.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { executionAsyncId, createHook, AsyncResource } = require('node:async_hooks') 4 | const { EventEmitter } = require('node:events') 5 | 6 | class CustomResource extends AsyncResource { 7 | constructor(type, traceId) { 8 | super(type) 9 | 10 | this.traceId = traceId 11 | } 12 | } 13 | 14 | class AsyncWatcher extends EventEmitter { 15 | setupInitHook() { 16 | // init is called during object construction. The resource may not have 17 | // completed construction when this callback runs, therefore all fields of the 18 | // resource referenced by "asyncId" may not have been populated. 19 | this.init = (asyncId, type, triggerAsyncId, resource) => { 20 | this.emit('INIT', { 21 | asyncId, 22 | type, 23 | triggerAsyncId, 24 | executionAsyncId: executionAsyncId(), 25 | resource, 26 | }) 27 | } 28 | return this 29 | } 30 | 31 | setupDestroyHook() { 32 | // Destroy is called when an AsyncWrap instance is destroyed. 33 | this.destroy = (asyncId) => { 34 | this.emit('DESTROY', { 35 | asyncId, 36 | executionAsyncId: executionAsyncId(), 37 | }) 38 | } 39 | return this 40 | } 41 | 42 | start() { 43 | createHook({ 44 | init: this.init.bind(this), 45 | destroy: this.destroy.bind(this), 46 | }).enable() 47 | 48 | return this 49 | } 50 | } 51 | 52 | class AsyncHookContainer { 53 | constructor(types) { 54 | const checkedTypes = types 55 | 56 | const idMap = new Map() 57 | const resourceMap = new Map() 58 | const watcher = new AsyncWatcher() 59 | const check = (t) => { 60 | try { 61 | return checkedTypes.includes(t) 62 | } catch { 63 | return false 64 | } 65 | } 66 | 67 | watcher 68 | .setupInitHook() 69 | .setupDestroyHook() 70 | .start() 71 | .on('INIT', ({ asyncId, type, resource, triggerAsyncId }) => { 72 | idMap.set(asyncId, triggerAsyncId) 73 | 74 | if (check(type)) { 75 | resourceMap.set(asyncId, resource) 76 | } 77 | }) 78 | .on('DESTROY', ({ asyncId }) => { 79 | idMap.delete(asyncId) 80 | resourceMap.delete(asyncId) 81 | }) 82 | 83 | this.types = checkedTypes 84 | this.idMap = idMap 85 | this.resourceMap = resourceMap 86 | this.watcher = watcher 87 | } 88 | 89 | getStore(asyncId) { 90 | let resource = this.resourceMap.get(asyncId) 91 | 92 | if (resource != null) { 93 | return resource 94 | } 95 | 96 | let id = this.idMap.get(asyncId) 97 | let sentinel = 0 98 | 99 | while (id != null && sentinel < 100) { 100 | resource = this.resourceMap.get(id) 101 | 102 | if (resource != null) { 103 | return resource 104 | } 105 | 106 | id = this.idMap.get(id) 107 | sentinel += 1 108 | } 109 | 110 | return undefined 111 | } 112 | } 113 | 114 | module.exports = { 115 | AsyncWatcher, 116 | AsyncHookContainer, 117 | CustomResource, 118 | } 119 | -------------------------------------------------------------------------------- /test/requestContextPlugin.e2e.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const request = require('superagent') 4 | const { describe, afterEach, test } = require('node:test') 5 | const { 6 | initAppPostWithPrevalidation, 7 | initAppPostWithAllPlugins, 8 | initAppGetWithDefaultStoreValues, 9 | } = require('./internal/appInitializer') 10 | const { TestService } = require('./internal/testService') 11 | 12 | describe('requestContextPlugin E2E', () => { 13 | let app 14 | afterEach(() => { 15 | return app.close() 16 | }) 17 | 18 | test('correctly preserves values set in prevalidation phase within single POST request', (t) => { 19 | t.plan(2) 20 | 21 | let testService 22 | let responseCounter = 0 23 | return new Promise((resolveResponsePromise) => { 24 | const promiseRequest2 = new Promise((resolveRequest2Promise) => { 25 | const promiseRequest1 = new Promise((resolveRequest1Promise) => { 26 | const route = (req) => { 27 | const requestId = req.requestContext.get('testKey') 28 | 29 | function prepareReply() { 30 | return testService.processRequest(requestId.replace('testValue', '')).then(() => { 31 | const storedValue = req.requestContext.get('testKey') 32 | return Promise.resolve({ storedValue }) 33 | }) 34 | } 35 | 36 | // We don't want to read values until both requests wrote their values to see if there is a racing condition 37 | if (requestId === 'testValue1') { 38 | resolveRequest1Promise() 39 | return promiseRequest2.then(prepareReply) 40 | } 41 | 42 | if (requestId === 'testValue2') { 43 | resolveRequest2Promise() 44 | return promiseRequest1.then(prepareReply) 45 | } 46 | 47 | throw new Error(`Unexpected requestId: ${requestId}`) 48 | } 49 | 50 | app = initAppPostWithPrevalidation(route) 51 | app.listen({ port: 0, host: '127.0.0.1' }).then(() => { 52 | testService = new TestService(app) 53 | const { address, port } = app.server.address() 54 | const url = `${address}:${port}` 55 | const response1Promise = request('POST', url) 56 | .send({ requestId: 1 }) 57 | .then((response) => { 58 | t.assert.deepStrictEqual(response.body.storedValue, 'testValue1') 59 | responseCounter++ 60 | if (responseCounter === 2) { 61 | resolveResponsePromise() 62 | } 63 | }) 64 | 65 | const response2Promise = request('POST', url) 66 | .send({ requestId: 2 }) 67 | .then((response) => { 68 | t.assert.deepStrictEqual(response.body.storedValue, 'testValue2') 69 | responseCounter++ 70 | if (responseCounter === 2) { 71 | resolveResponsePromise() 72 | } 73 | }) 74 | 75 | return Promise.all([response1Promise, response2Promise]) 76 | }) 77 | }) 78 | 79 | return promiseRequest1 80 | }) 81 | 82 | return promiseRequest2 83 | }) 84 | }) 85 | 86 | test('correctly preserves values set in multiple phases within single POST request', (t) => { 87 | t.plan(10) 88 | 89 | let testService 90 | let responseCounter = 0 91 | return new Promise((resolveResponsePromise) => { 92 | const promiseRequest2 = new Promise((resolveRequest2Promise) => { 93 | const promiseRequest1 = new Promise((resolveRequest1Promise) => { 94 | const route = (req) => { 95 | const onRequestValue = req.requestContext.get('onRequest') 96 | const preParsingValue = req.requestContext.get('preParsing') 97 | const preValidationValue = req.requestContext.get('preValidation') 98 | const preHandlerValue = req.requestContext.get('preHandler') 99 | 100 | t.assert.ok(!onRequestValue) 101 | t.assert.ok(!preParsingValue) 102 | t.assert.ok(typeof preValidationValue === 'number') 103 | t.assert.ok(typeof preHandlerValue === 'number') 104 | 105 | const requestId = `testValue${preHandlerValue}` 106 | 107 | function prepareReply() { 108 | return testService.processRequest(requestId.replace('testValue', '')).then(() => { 109 | const storedValue = req.requestContext.get('preValidation') 110 | return Promise.resolve({ storedValue: `testValue${storedValue}` }) 111 | }) 112 | } 113 | 114 | // We don't want to read values until both requests wrote their values to see if there is a racing condition 115 | if (requestId === 'testValue1') { 116 | resolveRequest1Promise() 117 | return promiseRequest2.then(prepareReply) 118 | } 119 | 120 | if (requestId === 'testValue2') { 121 | resolveRequest2Promise() 122 | return promiseRequest1.then(prepareReply) 123 | } 124 | 125 | throw new Error(`Unexpected requestId: ${requestId}`) 126 | } 127 | 128 | app = initAppPostWithAllPlugins(route, 'preValidation') 129 | 130 | app.listen({ port: 0, host: '127.0.0.1' }).then(() => { 131 | testService = new TestService(app) 132 | const { address, port } = app.server.address() 133 | const url = `${address}:${port}` 134 | const response1Promise = request('POST', url) 135 | .send({ requestId: 1 }) 136 | .then((response) => { 137 | t.assert.deepStrictEqual(response.body.storedValue, 'testValue1') 138 | responseCounter++ 139 | if (responseCounter === 2) { 140 | resolveResponsePromise() 141 | } 142 | }) 143 | 144 | const response2Promise = request('POST', url) 145 | .send({ requestId: 2 }) 146 | .then((response) => { 147 | t.assert.deepStrictEqual(response.body.storedValue, 'testValue2') 148 | responseCounter++ 149 | if (responseCounter === 2) { 150 | resolveResponsePromise() 151 | } 152 | }) 153 | 154 | return Promise.all([response1Promise, response2Promise]) 155 | }) 156 | }) 157 | 158 | return promiseRequest1 159 | }) 160 | 161 | return promiseRequest2 162 | }) 163 | }) 164 | 165 | test('does not lose request context after body parsing', (t) => { 166 | t.plan(7) 167 | const route = (req) => { 168 | const onRequestValue = req.requestContext.get('onRequest') 169 | const preParsingValue = req.requestContext.get('preParsing') 170 | const preValidationValue = req.requestContext.get('preValidation') 171 | const preHandlerValue = req.requestContext.get('preHandler') 172 | 173 | t.assert.deepStrictEqual(onRequestValue, 'dummy') 174 | t.assert.deepStrictEqual(preParsingValue, 'dummy') 175 | t.assert.ok(typeof preValidationValue === 'number') 176 | t.assert.ok(typeof preHandlerValue === 'number') 177 | 178 | const requestId = `testValue${preHandlerValue}` 179 | return Promise.resolve({ storedValue: requestId }) 180 | } 181 | 182 | app = initAppPostWithAllPlugins(route, 'onRequest') 183 | 184 | return app.listen({ port: 0, host: '127.0.0.1' }).then(() => { 185 | const { address, port } = app.server.address() 186 | const url = `${address}:${port}` 187 | return request('POST', url) 188 | .send({ requestId: 1 }) 189 | .then((response) => { 190 | t.assert.deepStrictEqual(response.body.storedValue, 'testValue1') 191 | t.assert.deepStrictEqual(response.body.preSerialization1, 'dummy') 192 | t.assert.deepStrictEqual(response.body.preSerialization2, 1) 193 | }) 194 | }) 195 | }) 196 | 197 | test('does not affect new request context when mutating context data using no default values object', (t) => { 198 | t.plan(2) 199 | 200 | const route = (req) => { 201 | const { action } = req.query 202 | if (action === 'setvalue') { 203 | req.requestContext.set('foo', 'abc') 204 | } 205 | 206 | return Promise.resolve({ userId: req.requestContext.get('foo') }) 207 | } 208 | 209 | app = initAppGetWithDefaultStoreValues(route, undefined) 210 | 211 | return app.listen({ port: 0, host: '127.0.0.1' }).then(() => { 212 | const { address, port } = app.server.address() 213 | const url = `${address}:${port}` 214 | 215 | return request('GET', url) 216 | .query({ action: 'setvalue' }) 217 | .then((response1) => { 218 | t.assert.deepStrictEqual(response1.body.userId, 'abc') 219 | 220 | return request('GET', url).then((response2) => { 221 | t.assert.ok(!response2.body.userId) 222 | }) 223 | }) 224 | }) 225 | }) 226 | 227 | test('does not affect new request context when mutating context data using default values object', (t) => { 228 | t.plan(2) 229 | 230 | const route = (req) => { 231 | const { action } = req.query 232 | if (action === 'setvalue') { 233 | req.requestContext.set('foo', 'abc') 234 | } 235 | 236 | return Promise.resolve({ userId: req.requestContext.get('foo') }) 237 | } 238 | 239 | app = initAppGetWithDefaultStoreValues(route, { 240 | foo: 'bar', 241 | }) 242 | 243 | return app.listen({ port: 0, host: '127.0.0.1' }).then(() => { 244 | const { address, port } = app.server.address() 245 | const url = `${address}:${port}` 246 | 247 | return request('GET', url) 248 | .query({ action: 'setvalue' }) 249 | .then((response1) => { 250 | t.assert.deepStrictEqual(response1.body.userId, 'abc') 251 | 252 | return request('GET', url).then((response2) => { 253 | t.assert.deepStrictEqual(response2.body.userId, 'bar') 254 | }) 255 | }) 256 | }) 257 | }) 258 | 259 | test('does not affect new request context when mutating context data using default values factory', (t) => { 260 | t.plan(2) 261 | 262 | const route = (req) => { 263 | const { action } = req.query 264 | if (action === 'setvalue') { 265 | req.requestContext.get('user').id = 'bob' 266 | } 267 | 268 | return Promise.resolve({ userId: req.requestContext.get('user').id }) 269 | } 270 | 271 | app = initAppGetWithDefaultStoreValues(route, () => ({ 272 | user: { id: 'system' }, 273 | })) 274 | 275 | return app.listen({ port: 0, host: '127.0.0.1' }).then(() => { 276 | const { address, port } = app.server.address() 277 | const url = `${address}:${port}` 278 | 279 | return request('GET', url) 280 | .query({ action: 'setvalue' }) 281 | .then((response1) => { 282 | t.assert.deepStrictEqual(response1.body.userId, 'bob') 283 | 284 | return request('GET', url).then((response2) => { 285 | t.assert.deepStrictEqual(response2.body.userId, 'system') 286 | }) 287 | }) 288 | }) 289 | }) 290 | 291 | test('ensure request instance is properly exposed to default values factory', (t) => { 292 | t.plan(1) 293 | 294 | const route = (req) => { 295 | return Promise.resolve({ userId: req.requestContext.get('user').id }) 296 | } 297 | 298 | app = initAppGetWithDefaultStoreValues(route, (req) => ({ 299 | user: { id: req.protocol }, 300 | })) 301 | 302 | return app.listen({ port: 0, host: '127.0.0.1' }).then(() => { 303 | const { address, port } = app.server.address() 304 | const url = `${address}:${port}` 305 | 306 | return request('GET', url).then((response1) => { 307 | t.assert.deepStrictEqual(response1.body.userId, 'http') 308 | }) 309 | }) 310 | }) 311 | 312 | test('does not throw when accessing context object outside of context', (t) => { 313 | t.plan(2) 314 | 315 | const route = (req) => { 316 | return Promise.resolve({ userId: req.requestContext.get('user').id }) 317 | } 318 | 319 | app = initAppGetWithDefaultStoreValues(route, { 320 | user: { id: 'system' }, 321 | }) 322 | 323 | return app.listen({ port: 0, host: '127.0.0.1' }).then(() => { 324 | const { address, port } = app.server.address() 325 | const url = `${address}:${port}` 326 | 327 | t.assert.ok(!app.requestContext.get('user')) 328 | 329 | return request('GET', url).then((response1) => { 330 | t.assert.deepStrictEqual(response1.body.userId, 'system') 331 | }) 332 | }) 333 | }) 334 | }) 335 | -------------------------------------------------------------------------------- /test/requestContextPlugin.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { 4 | initAppPost, 5 | initAppPostWithPrevalidation, 6 | initAppGet, 7 | initAppGetWithDefaultStoreValues, 8 | } = require('./internal/appInitializer') 9 | const { TestService } = require('./internal/testService') 10 | const { describe, afterEach, test } = require('node:test') 11 | 12 | describe('requestContextPlugin', () => { 13 | let app 14 | afterEach(() => { 15 | return app.close() 16 | }) 17 | 18 | test('correctly preserves values within single GET request', (t) => { 19 | t.plan(2) 20 | 21 | let testService 22 | let responseCounter = 0 23 | return new Promise((resolveResponsePromise) => { 24 | const promiseRequest2 = new Promise((resolveRequest2Promise) => { 25 | const promiseRequest1 = new Promise((resolveRequest1Promise) => { 26 | const route = (req, reply) => { 27 | function prepareReply() { 28 | return testService.processRequest(requestId).then(() => { 29 | const storedValue = req.requestContext.get('testKey') 30 | reply.status(200).send({ 31 | storedValue, 32 | }) 33 | }) 34 | } 35 | 36 | const requestId = Number.parseInt(req.query.requestId) 37 | req.requestContext.set('testKey', `testValue${requestId}`) 38 | 39 | // We don't want to read values until both requests wrote their values to see if there is a racing condition 40 | if (requestId === 1) { 41 | resolveRequest1Promise() 42 | return promiseRequest2.then(prepareReply) 43 | } 44 | 45 | if (requestId === 2) { 46 | resolveRequest2Promise() 47 | return promiseRequest1.then(prepareReply) 48 | } 49 | } 50 | 51 | initAppGet(route) 52 | .ready() 53 | .then((_app) => { 54 | app = _app 55 | testService = new TestService(app) 56 | const response1Promise = app 57 | .inject() 58 | .get('/') 59 | .query({ requestId: 1 }) 60 | .end() 61 | .then((response) => { 62 | t.assert.deepStrictEqual(response.json().storedValue, 'testValue1') 63 | responseCounter++ 64 | if (responseCounter === 2) { 65 | resolveResponsePromise() 66 | } 67 | }) 68 | 69 | const response2Promise = app 70 | .inject() 71 | .get('/') 72 | .query({ requestId: 2 }) 73 | .end() 74 | .then((response) => { 75 | t.assert.deepStrictEqual(response.json().storedValue, 'testValue2') 76 | responseCounter++ 77 | if (responseCounter === 2) { 78 | resolveResponsePromise() 79 | } 80 | }) 81 | 82 | return Promise.all([response1Promise, response2Promise]) 83 | }) 84 | }) 85 | 86 | return promiseRequest1 87 | }) 88 | 89 | return promiseRequest2 90 | }) 91 | }) 92 | 93 | test('correctly preserves values within single POST request', (t) => { 94 | t.plan(2) 95 | 96 | let testService 97 | let responseCounter = 0 98 | return new Promise((resolveResponsePromise) => { 99 | const promiseRequest2 = new Promise((resolveRequest2Promise) => { 100 | const promiseRequest1 = new Promise((resolveRequest1Promise) => { 101 | const route = (req, reply) => { 102 | function prepareReply() { 103 | return testService.processRequest(requestId).then(() => { 104 | const storedValue = req.requestContext.get('testKey') 105 | reply.status(200).send({ 106 | storedValue, 107 | }) 108 | }) 109 | } 110 | 111 | const requestId = Number.parseInt(req.body.requestId) 112 | req.requestContext.set('testKey', `testValue${requestId}`) 113 | 114 | // We don't want to read values until both requests wrote their values to see if there is a racing condition 115 | if (requestId === 1) { 116 | resolveRequest1Promise() 117 | return promiseRequest2.then(prepareReply) 118 | } 119 | 120 | if (requestId === 2) { 121 | resolveRequest2Promise() 122 | return promiseRequest1.then(prepareReply) 123 | } 124 | } 125 | 126 | initAppPost(route) 127 | .ready() 128 | .then((_app) => { 129 | app = _app 130 | testService = new TestService(app) 131 | const response1Promise = app 132 | .inject() 133 | .post('/') 134 | .body({ requestId: 1 }) 135 | .end() 136 | .then((response) => { 137 | t.assert.deepStrictEqual(response.json().storedValue, 'testValue1') 138 | responseCounter++ 139 | if (responseCounter === 2) { 140 | resolveResponsePromise() 141 | } 142 | }) 143 | 144 | const response2Promise = app 145 | .inject() 146 | .post('/') 147 | .body({ requestId: 2 }) 148 | .end() 149 | .then((response) => { 150 | t.assert.deepStrictEqual(response.json().storedValue, 'testValue2') 151 | responseCounter++ 152 | if (responseCounter === 2) { 153 | resolveResponsePromise() 154 | } 155 | }) 156 | 157 | return Promise.all([response1Promise, response2Promise]) 158 | }) 159 | }) 160 | 161 | return promiseRequest1 162 | }) 163 | 164 | return promiseRequest2 165 | }) 166 | }) 167 | 168 | test('correctly preserves values set in prevalidation phase within single POST request', (t) => { 169 | t.plan(2) 170 | 171 | let testService 172 | let responseCounter = 0 173 | return new Promise((resolveResponsePromise) => { 174 | const promiseRequest2 = new Promise((resolveRequest2Promise) => { 175 | const promiseRequest1 = new Promise((resolveRequest1Promise) => { 176 | const route = (req, reply) => { 177 | const requestId = req.requestContext.get('testKey') 178 | 179 | function prepareReply() { 180 | return testService.processRequest(requestId.replace('testValue', '')).then(() => { 181 | const storedValue = req.requestContext.get('testKey') 182 | reply.status(200).send({ 183 | storedValue, 184 | }) 185 | }) 186 | } 187 | 188 | // We don't want to read values until both requests wrote their values to see if there is a racing condition 189 | if (requestId === 'testValue1') { 190 | resolveRequest1Promise() 191 | return promiseRequest2.then(prepareReply) 192 | } 193 | 194 | if (requestId === 'testValue2') { 195 | resolveRequest2Promise() 196 | return promiseRequest1.then(prepareReply) 197 | } 198 | } 199 | 200 | initAppPostWithPrevalidation(route) 201 | .ready() 202 | .then((_app) => { 203 | app = _app 204 | testService = new TestService(app) 205 | const response1Promise = app 206 | .inject() 207 | .post('/') 208 | .body({ requestId: 1 }) 209 | .end() 210 | .then((response) => { 211 | t.assert.deepStrictEqual(response.json().storedValue, 'testValue1') 212 | responseCounter++ 213 | if (responseCounter === 2) { 214 | resolveResponsePromise() 215 | } 216 | }) 217 | 218 | const response2Promise = app 219 | .inject() 220 | .post('/') 221 | .body({ requestId: 2 }) 222 | .end() 223 | .then((response) => { 224 | t.assert.deepStrictEqual(response.json().storedValue, 'testValue2') 225 | responseCounter++ 226 | if (responseCounter === 2) { 227 | resolveResponsePromise() 228 | } 229 | }) 230 | 231 | return Promise.all([response1Promise, response2Promise]) 232 | }) 233 | }) 234 | 235 | return promiseRequest1 236 | }) 237 | 238 | return promiseRequest2 239 | }) 240 | }) 241 | 242 | test('does not affect new request context when mutating context data using default values factory', (t) => { 243 | t.plan(2) 244 | 245 | const route = (req, reply) => { 246 | const { action } = req.query 247 | if (action === 'setvalue') { 248 | req.requestContext.get('user').id = 'bob' 249 | } 250 | 251 | reply.status(200).send(req.requestContext.get('user').id) 252 | } 253 | 254 | return new Promise((resolve) => { 255 | initAppGetWithDefaultStoreValues(route, () => ({ 256 | user: { id: 'system' }, 257 | })) 258 | .ready() 259 | .then((app) => { 260 | const response1 = app 261 | .inject() 262 | .get('/') 263 | .query({ action: 'setvalue' }) 264 | .end() 265 | .then((response) => { 266 | t.assert.deepStrictEqual(response.body, 'bob') 267 | }) 268 | 269 | response1.then(() => { 270 | app 271 | .inject() 272 | .get('/') 273 | .end() 274 | .then((response) => { 275 | t.assert.deepStrictEqual(response.body, 'system') 276 | resolve() 277 | }) 278 | }) 279 | }) 280 | }) 281 | }) 282 | 283 | test('correctly preserves values for 204 responses', (t) => { 284 | t.plan(2) 285 | 286 | let testService 287 | let responseCounter = 0 288 | return new Promise((resolveResponsePromise) => { 289 | const promiseRequest2 = new Promise((resolveRequest2Promise) => { 290 | const promiseRequest1 = new Promise((resolveRequest1Promise) => { 291 | const route = (req, reply) => { 292 | function prepareReply() { 293 | return testService.processRequest(requestId).then(() => { 294 | const storedValue = req.requestContext.get('testKey') 295 | reply.status(204).header('storedvalue', storedValue).send() 296 | }) 297 | } 298 | 299 | const requestId = Number.parseInt(req.query.requestId) 300 | req.requestContext.set('testKey', `testValue${requestId}`) 301 | 302 | // We don't want to read values until both requests wrote their values to see if there is a racing condition 303 | if (requestId === 1) { 304 | resolveRequest1Promise() 305 | return promiseRequest2.then(prepareReply) 306 | } 307 | 308 | if (requestId === 2) { 309 | resolveRequest2Promise() 310 | return promiseRequest1.then(prepareReply) 311 | } 312 | } 313 | 314 | initAppGet(route) 315 | .ready() 316 | .then((_app) => { 317 | app = _app 318 | testService = new TestService(app) 319 | const response1Promise = app 320 | .inject() 321 | .get('/') 322 | .query({ requestId: 1 }) 323 | .end() 324 | .then((response) => { 325 | t.assert.deepStrictEqual(response.headers.storedvalue, 'testValue1') 326 | responseCounter++ 327 | if (responseCounter === 2) { 328 | resolveResponsePromise() 329 | } 330 | }) 331 | 332 | const response2Promise = app 333 | .inject() 334 | .get('/') 335 | .query({ requestId: 2 }) 336 | .end() 337 | .then((response) => { 338 | t.assert.deepStrictEqual(response.headers.storedvalue, 'testValue2') 339 | responseCounter++ 340 | if (responseCounter === 2) { 341 | resolveResponsePromise() 342 | } 343 | }) 344 | 345 | return Promise.all([response1Promise, response2Promise]) 346 | }) 347 | }) 348 | 349 | return promiseRequest1 350 | }) 351 | 352 | return promiseRequest2 353 | }) 354 | }) 355 | }) 356 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | "module": "commonjs", 5 | "target": "es2015", 6 | "sourceMap": true, 7 | "declaration": true, 8 | "declarationMap": false, 9 | "types": ["node", "jest"], 10 | "strict": true, 11 | "moduleResolution": "node", 12 | "noUnusedLocals": false, 13 | "noUnusedParameters": false, 14 | "noFallthroughCasesInSwitch": true, 15 | "noImplicitReturns": true, 16 | "noImplicitAny": true, 17 | "noImplicitThis": true, 18 | "strictNullChecks": true, 19 | "importHelpers": true, 20 | "baseUrl": ".", 21 | "allowSyntheticDefaultImports": true, 22 | "esModuleInterop": true, 23 | "forceConsistentCasingInFileNames": true, 24 | "exactOptionalPropertyTypes": true 25 | }, 26 | "exclude": [ 27 | "node_modules", 28 | "test", 29 | "dist" 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | import { AsyncLocalStorage, AsyncResource } from 'node:async_hooks' 2 | import { FastifyPluginCallback, FastifyRequest } from 'fastify' 3 | 4 | type FastifyRequestContext = 5 | FastifyPluginCallback 6 | 7 | declare module 'fastify' { 8 | interface FastifyRequest { 9 | requestContext: fastifyRequestContext.RequestContext 10 | } 11 | 12 | interface FastifyInstance { 13 | requestContext: fastifyRequestContext.RequestContext 14 | } 15 | } 16 | 17 | declare namespace fastifyRequestContext { 18 | export interface RequestContextData { 19 | // Empty on purpose, to be extended by users of this module 20 | } 21 | 22 | export interface RequestContext { 23 | get(key: K): RequestContextData[K] | undefined 24 | set(key: K, value: RequestContextData[K]): void 25 | getStore(): RequestContextData | undefined 26 | } 27 | 28 | export type CreateAsyncResourceFactory = ( 29 | req: FastifyRequest, 30 | context: RequestContext, 31 | ) => T 32 | 33 | export type RequestContextDataFactory = (req: FastifyRequest) => RequestContextData 34 | 35 | export type Hook = 36 | | 'onRequest' 37 | | 'preParsing' 38 | | 'preValidation' 39 | | 'preHandler' 40 | | 'preSerialization' 41 | | 'onSend' 42 | | 'onResponse' 43 | | 'onTimeout' 44 | | 'onError' 45 | | 'onRoute' 46 | | 'onRegister' 47 | | 'onReady' 48 | | 'onClose' 49 | 50 | export interface FastifyRequestContextOptions { 51 | defaultStoreValues?: RequestContextData | RequestContextDataFactory 52 | hook?: Hook 53 | createAsyncResource?: CreateAsyncResourceFactory 54 | } 55 | 56 | export const requestContext: RequestContext 57 | export const asyncLocalStorage: AsyncLocalStorage 58 | /** 59 | * @deprecated Use FastifyRequestContextOptions instead 60 | */ 61 | export type RequestContextOptions = FastifyRequestContextOptions 62 | 63 | /** 64 | * @deprecated Use fastifyRequestContext instead 65 | */ 66 | export const fastifyRequestContextPlugin: FastifyRequestContext 67 | 68 | export const fastifyRequestContext: FastifyRequestContext 69 | export { fastifyRequestContext as default } 70 | } 71 | 72 | declare function fastifyRequestContext( 73 | ...params: Parameters 74 | ): ReturnType 75 | export = fastifyRequestContext 76 | -------------------------------------------------------------------------------- /types/index.test-d.ts: -------------------------------------------------------------------------------- 1 | import { 2 | requestContext, 3 | asyncLocalStorage, 4 | fastifyRequestContext, 5 | FastifyRequestContextOptions, 6 | RequestContext, 7 | RequestContextDataFactory, 8 | } from '..' 9 | import { expectAssignable, expectType, expectError } from 'tsd' 10 | import { FastifyBaseLogger, FastifyInstance, RouteHandlerMethod } from 'fastify' 11 | import { AsyncLocalStorage } from 'node:async_hooks' 12 | 13 | const fastify = require('fastify') 14 | 15 | const app: FastifyInstance = fastify() 16 | app.register(fastifyRequestContext) 17 | 18 | declare module './index' { 19 | interface RequestContextData { 20 | a?: string 21 | log?: FastifyBaseLogger 22 | } 23 | } 24 | 25 | expectAssignable({}) 26 | expectAssignable({ 27 | defaultStoreValues: { a: 'dummy' }, 28 | }) 29 | expectAssignable({ 30 | hook: 'preValidation', 31 | defaultStoreValues: { a: 'dummy' }, 32 | }) 33 | expectAssignable({ 34 | defaultStoreValues: () => ({ 35 | a: 'dummy', 36 | }), 37 | }) 38 | 39 | expectError({ 40 | defaultStoreValues: { bar: 'dummy' }, 41 | }) 42 | 43 | expectError({ 44 | defaultStoreValues: { log: 'dummy' }, 45 | }) 46 | 47 | expectAssignable(() => ({ 48 | a: 'dummy', 49 | })) 50 | 51 | expectAssignable({ 52 | defaultStoreValues: (req) => ({ 53 | log: req.log.child({ childLog: true }), 54 | }), 55 | }) 56 | 57 | expectAssignable((req) => ({ 58 | log: req.log.child({ childLog: true }), 59 | })) 60 | 61 | expectError(() => ({ bar: 'dummy' })) 62 | expectError(() => ({ log: 'dummy' })) 63 | 64 | expectType(app.requestContext) 65 | expectType(requestContext) 66 | expectType>(asyncLocalStorage) 67 | 68 | const getHandler: RouteHandlerMethod = function (request, _reply) { 69 | expectType(request.requestContext) 70 | } 71 | 72 | expectType(requestContext.get('a')) 73 | expectType(requestContext.get('log')) 74 | 75 | expectError(requestContext.get('bar')) 76 | 77 | // Test exactOptionalPropertyTypes: true 78 | 79 | requestContext.set('a', undefined) // Should not error 80 | expectError(requestContext.set('a', 123)) 81 | expectError({ 82 | defaultStoreValues: { a: undefined }, 83 | }) 84 | --------------------------------------------------------------------------------