├── .babelrc ├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ └── node.js.yml ├── .gitignore ├── .nvmrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── configuration ├── jest.config.js └── webpack.config.js ├── example ├── controller.js ├── middleware.js ├── package.json ├── server.js └── static │ └── index.html ├── index.js ├── package-lock.json ├── package.json └── src ├── ExecutionContext ├── constants.js ├── index.js ├── spec.js └── types.d.ts ├── index.js ├── lib ├── ExecutionContextResource.js ├── index.js ├── spec.js └── types.d.ts ├── managers ├── asyncHooks │ ├── constants.js │ ├── hooks │ │ ├── constants.js │ │ ├── index.js │ │ ├── spec.js │ │ └── types.d.ts │ ├── index.js │ └── spec.js ├── asyncLocalStorage │ ├── constants.js │ ├── index.js │ └── spec.js └── index.js └── types.d.ts /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | root = true 3 | 4 | [*] 5 | end_of_line = lf 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | charset = utf-8 9 | indent_style = space 10 | tab_width = 4 11 | 12 | [{.*,*.{json,yml}}] 13 | indent_size = 2 14 | 15 | [*.js] 16 | indent_size = 4 17 | 18 | [*.sh] 19 | indent_style = tab 20 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | example 4 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: 'eslint:recommended', 3 | root: true, 4 | parserOptions: { 5 | ecmaVersion: 2018, 6 | sourceType: 'module' 7 | }, 8 | env: { 9 | node: true, 10 | browser: true, 11 | es6: true 12 | }, 13 | rules: { 14 | 'no-console': 2, 15 | 'no-var': 2, 16 | 'prefer-const': 2, 17 | semi: 2, 18 | quotes: [2, 'single'] 19 | }, 20 | overrides: [ 21 | { 22 | files: ['**/spec.js', '**/*.spec.js'], 23 | env: { 24 | jest: true 25 | } 26 | } 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [8.x, 10.x, 12.x, 14.x, 16.x] 20 | 21 | steps: 22 | - uses: actions/checkout@v2 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v1 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | - run: npm ci 28 | - run: npm run build --if-present 29 | - run: npm test 30 | - run: npm run lint 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OS 2 | .DS_Store 3 | 4 | # Logs 5 | *.log 6 | 7 | # Dependency directories 8 | node_modules 9 | 10 | dist 11 | 12 | # Optional npm cache directory 13 | .npm 14 | 15 | .idea/ 16 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 12 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## 3.1.0 (February 12, 2021) 4 | 5 | - Add `Context.exists` API to validate context presence before accessing it. 6 | 7 | ## 3.0.2 (February 12, 2021) 8 | 9 | - Add `update` API for ease usage in case context is a plain `object`. 10 | 11 | ## 3.0.1 (February 12, 2021) 12 | 13 | - Better `types.d`. 14 | 15 | ## 3.0.0 (January 03, 2021) 16 | 17 | - Introduces `AsyncLocaleStorage` based implementation for node `12.7.0` and above. 18 | - `AsyncHooksContext` domains are now created by default under the hood and no longer require consumers to supply a name. 19 | - `update` API changed to `set`. 20 | - `create` API no longer treats`context` as JS objects. 21 | 22 | ## 2.0.8 (December 20, 2020) 23 | 24 | - Better reporting, safe child getters. 25 | 26 | ## 2.0.7 (December 20, 2020) 27 | 28 | - Preferring `setImmediate` over `nextTick`. 29 | 30 | ## 2.0.6 (December 2, 2020) 31 | 32 | - Better types definition for get. 33 | 34 | ## 2.0.5 (October 27, 2020) 35 | 36 | - publish. 37 | 38 | ## 2.0.4 (October 27, 2020) 39 | 40 | - Versions. 41 | 42 | ## 2.0.3 (October 27, 2020) 43 | 44 | ### Improvements 45 | 46 | - Domains error logging enhancement. 47 | 48 | ## 2.0.2 (October 27, 2020) 49 | 50 | ### Bug Fixes 51 | 52 | - Domains root context fetching, extracted to private `getRootContext`. 53 | 54 | ## 2.0.1 (September 22, 2020) 55 | 56 | ### Improvements 57 | 58 | - Update changelog. 59 | 60 | ## 2.0.0 (September 19, 2020) 61 | 62 | ### New features 63 | 64 | - Domains - allow creating a domain under a certain context to split the chain into a standalone context root (parent chain will not depend on its completion for release). 65 | - Configurations settings. 66 | 67 | ### Improvements 68 | 69 | - Favoring direct assign over spread calls. 70 | - Monitor now is controlled by config, will not enrich execution context nodes by default. 71 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 odedglas 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 | # node-execution-context 2 | A straightforward library that provides a persistent process-level context wrapper using node "async_hooks" feature. 3 | This library will try to use by default [`AsyncLocalStorage`](https://nodejs.org/api/async_hooks.html#async_hooks_class_asynclocalstorage) implementation based if current node version supports it, otherwise it will fallback to raw [`async_hooks`](https://nodejs.org/api/async_hooks.html) implementation for lower versions which mimics this behaviour. 4 | 5 | ## Installation 6 | 7 | ``` 8 | npm i node-execution-context 9 | ``` 10 | 11 | ## Getting started 12 | 13 | Let's start with creating the context initialisation point of our app, we will take a simple express app for this example 14 | 15 | ```js 16 | // main.js 17 | 18 | const express = require('express'); 19 | const Context = require('node-execution-context'); 20 | const UserController = require('./controllers/user'); 21 | const app = express(); 22 | const port = 3000; 23 | 24 | const ContextMiddleware = (req, res, next) => { 25 | Context.run(next, { reference: Math.random() }); 26 | }; 27 | 28 | app.use('/', ContextMiddleware); 29 | app.post('/user', UserController.create); 30 | 31 | app.listen(port); 32 | 33 | ``` 34 | 35 | This will expose any point of your code form this point that handles that request. 36 | 37 | ```js 38 | // ./controller/user/index.js 39 | 40 | const Context = require('node-execution-context'); 41 | const mongo = require('../service/mongo'); 42 | const logger = require('../service/logger'); 43 | 44 | export class UserController { 45 | async create (req) { 46 | const { user } = req.body; 47 | 48 | // This will return the reference number set by out ContextMiddleware (generated by Math.random()) 49 | const { reference } = Context.get(); 50 | 51 | logger.info('Created user for reference: ', reference); 52 | 53 | return await mongo.create('user', user); 54 | } 55 | } 56 | ``` 57 | 58 | ## API 59 | 60 | ### run(fn: Function, context: unknown) 61 | 62 | Runs given callback that will be exposed to the given context. 63 | The context will be exposed to all callbacks and promise chains triggered from the given `fn`. 64 | 65 | ### get() 66 | 67 | Gets the current asynchronous execution context. 68 | 69 | > The `get` result is returned by `reference` meaning if you wish any immutability applied, it will have to be manually applied. 70 | 71 | > This API may throw CONTEXT_DOES_NOT_EXIST error if accessed without initializing the context properly. 72 | 73 | ### set(context: unknown) 74 | 75 | Sets the current asynchronous execution context to given value. 76 | 77 | > This API may throw CONTEXT_DOES_NOT_EXIST error if accessed without initializing the context properly. 78 | 79 | ### create(context?: unknown) 80 | 81 | Creates a given context for the current asynchronous execution. 82 | It is recommended to use the `run` method. This method should be used in special cases in which the `run` method cannot be used directly. 83 | 84 | > Note that if this method will be called not within a AsyncResource context, it will effect current execution context and should be used with caution. 85 | 86 | #### Example 87 | 88 | ```js 89 | const Context = require('node-execution-context'); 90 | 91 | // context-events.js 92 | const context = { id: Math.random() }; 93 | 94 | emitter.on('contextual-event', () => { 95 | Context.create(context); 96 | }); 97 | 98 | // service.js 99 | emitter.on('contextual-event', () => { 100 | Context.get(); // Returns { id: random } 101 | }); 102 | 103 | // main.js 104 | emitter.emit('contextual-event'); 105 | 106 | Context.get(); // Returns { id: random } 107 | ``` 108 | 109 | ### configure(config: ExecutionContextConfig) : void 110 | 111 | Configures execution context settings. 112 | 113 | > Relevant only for node versions lower than `v12.17.0`. 114 | 115 | ### monitor(): ExecutionMapUsage 116 | 117 | Returns an monitoring report over the current execution map resources 118 | 119 | > Relevant only for node versions lower than `v12.17.0`. 120 | 121 | > Before calling `monitor`, you should `configure` execution context to monitor it's nodes. by default the data kept is as possible. 122 | 123 | #### Example 124 | 125 | ```js 126 | const Context = require('node-execution-context'); 127 | 128 | // Startup 129 | Context.configure({ monitor: true }); 130 | 131 | // Later on 132 | const usage = Context.monitor(); 133 | console.log(usage); // Prints execution context usage report. 134 | ``` 135 | 136 | ### Raw API Usage 137 | 138 | ```js 139 | const Context = require('node-execution-context'); 140 | 141 | Context.create({ 142 | value: true 143 | }); 144 | 145 | Promise.resolve().then(() => { 146 | console.log(Context.get()); // outputs: {"value": true} 147 | 148 | Context.set({ 149 | value: false 150 | }); 151 | 152 | return new Promise((resolve) => { 153 | setTimeout(() => { 154 | console.log(Context.get()); // outputs: {"value": false} 155 | 156 | Context.set({ 157 | butter: 'fly' 158 | }); 159 | 160 | process.nextTick(() => { 161 | console.log(Context.get()); // outputs: {"butter": 'fly'} 162 | resolve(); 163 | }); 164 | 165 | }, 1000); 166 | 167 | console.log(Context.get()); // outputs: {"value": false} 168 | }); 169 | }); 170 | ``` 171 | 172 | The following errors can be thrown while accessing to the context API : 173 | 174 | | Code | When | 175 | |-|- 176 | | CONTEXT_DOES_NOT_EXIST | When attempting to `get` / `set` a context, that has not yet been created. 177 | | MONITOR_MISS_CONFIGURATION | When try to `monitor` without calling `configure` with monitoring option. 178 | 179 | -------------------------------------------------------------------------------- /configuration/jest.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const appRoot = path.resolve('.'); 4 | 5 | module.exports = { 6 | verbose: true, 7 | rootDir: appRoot, 8 | collectCoverageFrom: [ 9 | '**/*.{mjs,js}' 10 | ], 11 | moduleFileExtensions: [ 12 | 'js', 'json', 'jsx', 'mjs' 13 | ], 14 | testURL: 'http://localhost/', 15 | transform: { 16 | '^.+\\.m?jsx?$': 'babel-jest' 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /configuration/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { env, isProduction } = require('../src/lib'); 3 | 4 | const root = path.resolve(__dirname, '..'); 5 | 6 | module.exports = { 7 | mode: env, 8 | devtool: 'source-map', 9 | entry: path.join(root, 'index.js'), 10 | target: 'node', 11 | output: { 12 | filename: 'index.js', 13 | path: path.join(root, 'dist'), 14 | libraryTarget: 'umd', 15 | globalObject: 'typeof self !== \'undefined\' ? self : this' 16 | }, 17 | module: { 18 | rules: [ 19 | { 20 | test: /\.js$/, 21 | loader: 'babel-loader', 22 | include: root, 23 | exclude: /node_modules/ 24 | } 25 | ] 26 | }, 27 | optimization: { 28 | minimize: false 29 | }, 30 | performance: { 31 | hints: isProduction() ? 'warning' : false 32 | }, 33 | resolve: { 34 | extensions: ['.js', '.json'] 35 | } 36 | }; 37 | -------------------------------------------------------------------------------- /example/controller.js: -------------------------------------------------------------------------------- 1 | const Context = require('../src'); 2 | 3 | const delay = (callback, timeout = 1000) => setTimeout(() => { 4 | callback(); 5 | }, timeout); 6 | 7 | class UserController { 8 | get(req, res) { 9 | 10 | delay(() => { 11 | console.log('Callback1: ', Context.get()); // Callback: { val: true } 12 | }); 13 | 14 | // Creates another context under Timeout AsyncResource un effected/effecting from current one. 15 | setTimeout(() => { 16 | Context.create({ value: 'domain' }); 17 | console.log('Domain: ', Context.get()); // Promise: { val: 'domain' } 18 | 19 | delay(() => { 20 | console.log('Domain Callback: ', Context.get()) // Promise: { val: 'domain' } 21 | }, 3000); 22 | }, 300); 23 | 24 | delay(() => { 25 | console.log('Callback2: ', Context.get()); // Callback: { val: true } 26 | }, 500); 27 | 28 | res.send(Context.get()); // { val: true } 29 | } 30 | } 31 | 32 | module.exports = new UserController(); 33 | -------------------------------------------------------------------------------- /example/middleware.js: -------------------------------------------------------------------------------- 1 | const Context = require('../src'); 2 | 3 | module.exports = (req, res, next) => { 4 | Context.run(next, { val: true }); 5 | }; 6 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sample-express-server", 3 | "version": "1.0.0", 4 | "description": "sample", 5 | "main": "server.js", 6 | "scripts": { 7 | "start": "node server.js" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/ceroloy/sample-express-app" 12 | }, 13 | "keywords": [ 14 | "IBM", 15 | "Cloud" 16 | ], 17 | "author": "IBM", 18 | "license": "Apache-2.0", 19 | "dependencies": { 20 | "express": "^4.16.2", 21 | "node-execution-context": "^1.0.1" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /example/server.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const UserController = require('./controller'); 3 | const ContextMiddleware = require('./middleware'); 4 | 5 | const app = express(); 6 | 7 | app.get('/', function(req,res){ 8 | res.sendFile(__dirname + '/static/index.html'); 9 | }); 10 | 11 | const port = process.env.PORT || 8080; 12 | 13 | app.use('/', ContextMiddleware); 14 | 15 | app.get('/user', UserController.get); 16 | 17 | app.listen(port, () => { 18 | console.log('Server is running'); 19 | }); -------------------------------------------------------------------------------- /example/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./src'); 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-execution-context", 3 | "version": "3.1.0", 4 | "description": "Provides execution context wrapper for node JS, can be used to create execution wrapper for handling requests and more", 5 | "author": "Oded Goldglas ", 6 | "license": "ISC", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/odedglas/node-execution-context" 10 | }, 11 | "homepage": "https://github.com/odedglas/node-execution-context", 12 | "main": "dist/index.js", 13 | "types": "src/types.d.ts", 14 | "scripts": { 15 | "test": "jest -c configuration/jest.config.js", 16 | "lint": "eslint .", 17 | "build": "webpack --config configuration/webpack.config", 18 | "publish-package": "npm run build && npm publish" 19 | }, 20 | "keywords": [ 21 | "node", 22 | "context", 23 | "execution-context", 24 | "async-hooks" 25 | ], 26 | "engines": { 27 | "node": ">=8.6.0" 28 | }, 29 | "engineStrict": true, 30 | "devDependencies": { 31 | "@babel/cli": "^7.1.5", 32 | "@babel/core": "^7.1.5", 33 | "@babel/preset-env": "^7.1.5", 34 | "babel-core": "^7.0.0-bridge.0", 35 | "babel-jest": "^23.6.0", 36 | "babel-loader": "^8.0.4", 37 | "eslint": "^5.9.0", 38 | "jest": "^23.6.0", 39 | "regenerator-runtime": "^0.12.1", 40 | "uglifyjs-webpack-plugin": "^2.0.1", 41 | "webpack": "^4.25.1", 42 | "webpack-cli": "^3.1.2" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/ExecutionContext/constants.js: -------------------------------------------------------------------------------- 1 | /** 2 | * The Execution context error that can be thrown while accessing the execution context. 3 | * @type {Object} 4 | */ 5 | exports.ExecutionContextErrors = { 6 | CONTEXT_DOES_NOT_EXIST: 'Execution context does not exists, please ensure to call create/run before.', 7 | UPDATE_BLOCKED: 'Calling "update" API is allowed only when provided context is a plain `object`.', 8 | MONITOR_MISS_CONFIGURATION: 'Monitoring option is off by default, please call `configure` with the proper options.' 9 | }; 10 | 11 | /** 12 | * The default configuration to use. 13 | * @type {ExecutionContextConfig} 14 | */ 15 | exports.DEFAULT_CONFIG = { 16 | monitor: false 17 | }; 18 | -------------------------------------------------------------------------------- /src/ExecutionContext/index.js: -------------------------------------------------------------------------------- 1 | const { supportAsyncLocalStorage } = require('../lib'); 2 | const { AsyncHooksContext, AsyncLocalStorageContext } = require('../managers'); 3 | 4 | /** 5 | * @type {ExecutionContextAPI} 6 | */ 7 | class ExecutionContext { 8 | constructor() { 9 | const ContextManager = supportAsyncLocalStorage() ? AsyncLocalStorageContext : AsyncHooksContext; 10 | 11 | this.manager = new ContextManager(); 12 | } 13 | 14 | /** 15 | * Creates a given context for the current asynchronous execution. 16 | * Note that this method will override the current execution context if called twice within the same `AsyncResource`. 17 | * Calling this method in a separate `AsyncResource` will create a new execution context for that resource. 18 | * @param {*} context - The context to expose. 19 | * @return void 20 | */ 21 | create(context) { 22 | this.manager.create(context); 23 | } 24 | 25 | /** 26 | * Sets the current asynchronous execution context to given value. 27 | * @param {*} context - The new context to expose current asynchronous execution. 28 | * @returns void 29 | */ 30 | set(context) { 31 | this.manager.set(context); 32 | } 33 | 34 | /** 35 | * Updates the current asynchronous execution context with a given value. 36 | * @param {*} context - The new context to expose current asynchronous execution. 37 | * @returns void 38 | */ 39 | update(context) { 40 | this.manager.update(context); 41 | } 42 | 43 | /** 44 | * Check if the current asynchronous execution context exists. 45 | * @return {boolean} 46 | */ 47 | exists() { 48 | return this.manager.exists(); 49 | } 50 | 51 | /** 52 | * Gets the current asynchronous execution context. 53 | * @return {*} 54 | */ 55 | get() { 56 | return this.manager.get(); 57 | } 58 | 59 | /** 60 | * Runs given callback and exposed under a given context. 61 | * This will expose the given context within all callbacks and promise chains that will be called from given fn. 62 | * @param {Function} fn - The function to run. 63 | * @param {*} context - The context to expose. 64 | */ 65 | run(fn, context) { 66 | return this.manager.run(fn, context); 67 | } 68 | 69 | /** 70 | * Configures current execution context manager. 71 | * [Note] Relevant for node v12.17.0 and below 72 | * @param {ExecutionContextConfig} config - the configuration to use. 73 | */ 74 | configure(config) { 75 | this.manager.configure(config); 76 | } 77 | 78 | /** 79 | * Monitors current execution map usage 80 | * [Note] Relevant for node v12.17.0 and below 81 | * @return {ExecutionMapUsage|undefined} 82 | */ 83 | monitor() { 84 | return this.manager.monitor(); 85 | } 86 | } 87 | 88 | module.exports = ExecutionContext; 89 | -------------------------------------------------------------------------------- /src/ExecutionContext/spec.js: -------------------------------------------------------------------------------- 1 | const { ExecutionContextErrors } = require('./constants'); 2 | 3 | describe('Context', () => { 4 | let Context; 5 | beforeEach(() => { 6 | const ExecutionContext = jest.requireActual('.'); 7 | Context = new ExecutionContext(); 8 | }); 9 | 10 | describe('Api', () => { 11 | describe('Create', () => { 12 | it('Creates an execution context', () => { 13 | const context = { val: true }; 14 | Context.create(context); 15 | 16 | expect(Context.get()).toEqual(context); 17 | }); 18 | }); 19 | 20 | describe('Exists', () => { 21 | it('Returns false when context is not created', () => { 22 | expect(Context.exists()).toBeFalsy(); 23 | }); 24 | 25 | describe('When context is exists', () => { 26 | it('Returns true', () => { 27 | Context.create({ val: 'value' }); 28 | 29 | expect(() => Context.exists()).toBeTruthy(); 30 | }); 31 | }); 32 | }); 33 | 34 | describe('Get', () => { 35 | it('Throws an error when context is not created', () => { 36 | expect(() => Context.get()) 37 | .toThrow(ExecutionContextErrors.CONTEXT_DOES_NOT_EXIST); 38 | }); 39 | 40 | describe('When context is created', () => { 41 | it('Returns context', () => { 42 | Context.create({ val: 'value' }); 43 | const context = Context.get(); 44 | 45 | expect(context.val).toEqual('value'); 46 | }); 47 | }); 48 | }); 49 | 50 | describe('Set', () => { 51 | it('Throws an error when context is not created', () => { 52 | expect(() => Context.get()) 53 | .toThrow(ExecutionContextErrors.CONTEXT_DOES_NOT_EXIST); 54 | }); 55 | 56 | describe('When context is created', () => { 57 | it('Set context', () => { 58 | Context.create({ val: 'value' }); 59 | const context = Context.get(); 60 | 61 | expect(context.val).toEqual('value'); 62 | 63 | Context.set({ val: false }); 64 | expect(Context.get().val).toBeFalsy(); 65 | }); 66 | }); 67 | }); 68 | 69 | describe('Update', () => { 70 | it('Throws an error when context is not created', () => { 71 | expect(() => Context.get()) 72 | .toThrow(ExecutionContextErrors.CONTEXT_DOES_NOT_EXIST); 73 | }); 74 | 75 | it('Throws an error when context is a pain `object`', () => { 76 | Context.create({ my: 'thing' }); 77 | 78 | expect(() => Context.update('Hey')) 79 | .toThrow(ExecutionContextErrors.UPDATE_BLOCKED); 80 | }); 81 | 82 | describe('When context is created', () => { 83 | it('Update context', () => { 84 | Context.create({ val: 'value', other: 'o' }); 85 | const context = Context.get(); 86 | 87 | expect(context.val).toEqual('value'); 88 | 89 | Context.update({ val: false }); 90 | expect(Context.get()).toMatchObject({ 91 | val: false, 92 | other: 'o' 93 | }); 94 | }); 95 | }); 96 | }); 97 | 98 | describe('Run', () => { 99 | let spies; 100 | let execute; 101 | const initialContext = { initial: 'value' }; 102 | 103 | beforeEach(() => { 104 | execute = jest.fn(); 105 | spies = { 106 | execute 107 | }; 108 | 109 | Context.run(execute, initialContext); 110 | }); 111 | 112 | it('Executes given function', () => { 113 | expect(spies.execute).toHaveBeenCalledTimes(1); 114 | }); 115 | 116 | it('Expose context to function execution', () => { 117 | let exposedContext = undefined; 118 | execute = jest.fn(() => { 119 | exposedContext = Context.get(); 120 | }); 121 | 122 | Context.run(execute, initialContext); 123 | expect(exposedContext).toEqual(expect.objectContaining( 124 | initialContext 125 | )); 126 | }); 127 | 128 | describe('Errors', () => { 129 | const runWithinPromise = () => new Promise((resolve, reject) => { 130 | const error = new Error('Promise failed'); 131 | Context.run(() => reject(error)); 132 | }); 133 | 134 | it('Bubbles up error', () => { 135 | const errorFn = () => { 136 | throw new Error('That went badly.'); 137 | }; 138 | expect(() => Context.run(errorFn)).toThrow(); 139 | }); 140 | 141 | it('Rejects promises', async (done) => { 142 | try { 143 | await runWithinPromise(); 144 | expect(true).toBeFlasy(); 145 | } catch (e) { 146 | expect(e).toBeInstanceOf(Error); 147 | done(); 148 | } 149 | }); 150 | }); 151 | }); 152 | }); 153 | 154 | describe('Context Availability', () => { 155 | const create = () => Context.create({ hey: true }); 156 | const get = () => Context.get().hey; 157 | 158 | it('Support timeouts', (done) => { 159 | create(); 160 | 161 | setTimeout(() => { 162 | expect(get()).toBeTruthy(); 163 | Context.set({ hey: false }); 164 | 165 | setTimeout(() => { 166 | expect(get()).toBeFalsy(); 167 | done(); 168 | }, 200); 169 | }, 200); 170 | }); 171 | 172 | it('Support micro tasks', (done) => { 173 | create(); 174 | 175 | const microTask = () => new Promise((resolve) => { 176 | setTimeout(resolve, 200); 177 | }); 178 | 179 | microTask().then(() => { 180 | expect(get()).toBeTruthy(); 181 | Context.set({ hey: false }); 182 | 183 | microTask().then(() => { 184 | expect(get()).toBeFalsy(); 185 | done(); 186 | }); 187 | }); 188 | 189 | }); 190 | 191 | it('Support next ticks', (done) => { 192 | create(); 193 | 194 | process.nextTick(() => { 195 | expect(get()).toBeTruthy(); 196 | Context.set({ hey: false }); 197 | 198 | process.nextTick(() => { 199 | expect(get()).toBeFalsy(); 200 | done(); 201 | }); 202 | }); 203 | }); 204 | 205 | describe('Support Domains', () => { 206 | it('Allows to create sub domains under a root context', (done) => { 207 | create(); 208 | 209 | expect(Context.get().hey).toBeTruthy(); 210 | 211 | setTimeout(() => { 212 | Context.create({ some: 'where' }, 'that-domain'); 213 | Context.set({ hey: false }); 214 | 215 | expect(Context.get().hey).toBeFalsy(); 216 | }, 500); 217 | 218 | setTimeout(() => { 219 | expect(Context.get().hey).toBeTruthy(); 220 | 221 | done(); 222 | }, 1500); 223 | }); 224 | }); 225 | }); 226 | }); 227 | -------------------------------------------------------------------------------- /src/ExecutionContext/types.d.ts: -------------------------------------------------------------------------------- 1 | interface ExecutionContextNode { 2 | asyncId: number; 3 | monitor: boolean; 4 | ref? :number; 5 | children?: number[]; 6 | context?: object; 7 | created?: number; 8 | } 9 | 10 | type ExecutionContextMap = Map; 11 | 12 | interface ExecutionContextConfig { 13 | monitor: boolean; 14 | } 15 | 16 | export { 17 | ExecutionContextNode, 18 | ExecutionContextMap, 19 | ExecutionContextConfig 20 | } 21 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const ExecutionContext = require('./ExecutionContext'); 2 | 3 | // Ensures only 1 instance exists per runtime. 4 | global.ExecutionContext = global.ExecutionContext || new ExecutionContext(); 5 | 6 | module.exports = global.ExecutionContext; 7 | -------------------------------------------------------------------------------- /src/lib/ExecutionContextResource.js: -------------------------------------------------------------------------------- 1 | const { AsyncResource } = require('async_hooks'); 2 | 3 | const ASYNC_RESOURCE_TYPE = 'REQUEST_CONTEXT'; 4 | 5 | /** 6 | * Wraps node AsyncResource with predefined type. 7 | */ 8 | class ExecutionContextResource extends AsyncResource { 9 | constructor() { 10 | super(ASYNC_RESOURCE_TYPE); 11 | } 12 | } 13 | 14 | module.exports = ExecutionContextResource; -------------------------------------------------------------------------------- /src/lib/index.js: -------------------------------------------------------------------------------- 1 | const semver = require('semver'); 2 | const ExecutionContextResource = require('./ExecutionContextResource'); 3 | 4 | /** 5 | * The production environment 6 | * @type {String} 7 | */ 8 | const PRODUCTION = 'production'; 9 | 10 | const env = process.env.NODE_ENV || PRODUCTION; 11 | 12 | /** 13 | * Calculates a duration between a given moment and now 14 | * @param {Number} now - The current time 15 | * @param {Number} created - The created time to calculate it's duration 16 | * @return {Number} 17 | */ 18 | const getDuration = (now, created) => now - created; 19 | 20 | /** 21 | * Checks if current environment matches production. 22 | * @param {String} environment - The current environment. 23 | * @return {Boolean} 24 | */ 25 | const isProduction = (environment = env) => environment === PRODUCTION; 26 | 27 | module.exports = { 28 | ExecutionContextResource, 29 | env, 30 | isProduction, 31 | 32 | /** 33 | * Checks if a given value is undefined. 34 | * @param {*} input - that input to check. 35 | * @return {Boolean} 36 | */ 37 | isUndefined: (input) => [null, undefined].includes(input), 38 | 39 | /** 40 | * Handles execution context error, throws when none production. 41 | * @param {String} code - The error code to log. 42 | */ 43 | handleError: (code) => { 44 | const error = new Error(code); 45 | 46 | if (!isProduction()) { 47 | throw error; 48 | } 49 | 50 | console.error(error); // eslint-disable-line no-console 51 | }, 52 | 53 | /** 54 | * Checks if current node version supports async local storage. 55 | * @param {String} version The version to check. 56 | * @see https://nodejs.org/api/async_hooks.html#async_hooks_class_asynclocalstorage 57 | * @return {Boolean} 58 | */ 59 | supportAsyncLocalStorage: (version = process.version) => semver.gte(version, '12.17.0'), 60 | 61 | /** 62 | * Returns true if a given thing is an object 63 | * @param value - The thing to check. 64 | */ 65 | isObject: (value) => !!value && 66 | !Array.isArray(value) && 67 | typeof value === 'object', 68 | 69 | /** 70 | * Returns a monitoring report over the "executionContext" memory usage. 71 | * @param {ExecutionContextMap} executionContextMap The execution map to monitor. 72 | * @return {ExecutionMapUsage} 73 | */ 74 | monitorMap: (executionContextMap) => { 75 | const now = Date.now(); 76 | const mapEntries = [...executionContextMap.values()]; 77 | const entries = mapEntries 78 | .filter(({ children }) => !!children) 79 | .map(({ asyncId, created, children, domain, context = {} }) => ({ 80 | asyncId, 81 | created, 82 | domain, 83 | contextSize: JSON.stringify(context).length, 84 | duration: getDuration(now, created), 85 | children: children.map((childId) => { 86 | const { type, created } = executionContextMap.get(childId) || {}; 87 | if (!type) return; 88 | 89 | return { asyncId: childId, type, created, duration: getDuration(now, created) }; 90 | }).filter(Boolean) 91 | })); 92 | 93 | return { 94 | size: executionContextMap.size, 95 | entries 96 | }; 97 | } 98 | }; 99 | -------------------------------------------------------------------------------- /src/lib/spec.js: -------------------------------------------------------------------------------- 1 | const lib = require ('.'); 2 | const { ExecutionContextErrors } = require('../ExecutionContext/constants'); 3 | const { AsyncHooksContext } = require('../managers'); 4 | 5 | describe('Lib', () => { 6 | describe('isProduction', () => { 7 | 8 | it.each([ 9 | 'something', 10 | 'dev' 11 | ])('Returns falsy when env - (%p) is not production', (env) => { 12 | expect(lib.isProduction(env)).toBeFalsy(); 13 | }); 14 | 15 | it('Returns truthy when running on production', () => { 16 | expect(lib.isProduction('production')).toBeTruthy(); 17 | }); 18 | }); 19 | 20 | describe('isUndefined', () => { 21 | it.each([ 22 | 'String', 23 | false, 24 | {}, 25 | 1 26 | ])('Returns falsy when given value is defined', (val) => { 27 | expect(lib.isUndefined(val)).toBeFalsy(); 28 | }); 29 | 30 | it.each([ 31 | undefined, 32 | null 33 | ])('Returns truthy when value is undefined', (val) => { 34 | expect(lib.isUndefined(val)).toBeTruthy(); 35 | }); 36 | }); 37 | 38 | describe('isObject', () => { 39 | it.each([ 40 | [false, false], 41 | [1, false], 42 | ['', false], 43 | [undefined, false], 44 | [[], false], 45 | [() => {}, false], 46 | [class Test {}, false], 47 | [{}, true], 48 | ])('Returns true when given value is object (%p)', (value, expected) => { 49 | expect(lib.isObject(value)).toEqual(expected); 50 | }); 51 | }); 52 | 53 | describe('supportAsyncLocalStorage', () => { 54 | describe('When node version is lower than 12.17.0', () => { 55 | it.each([ 56 | 'v6.8.0', 57 | 'v10.9.11', 58 | 'v12.16.9' 59 | ])('Return false', (version) => expect(lib.supportAsyncLocalStorage(version)).toBeFalsy()); 60 | }); 61 | 62 | describe('When node version is greater than 12.17.0', () => { 63 | describe('When node version is lower than 12.17.0', () => { 64 | it.each([ 65 | 'v12.17.0', 66 | 'v14.6.0', 67 | 'v15.1.0', 68 | ])('Return false', (version) => expect(lib.supportAsyncLocalStorage(version)).toBeTruthy()); 69 | }); 70 | }); 71 | }); 72 | 73 | describe('monitorMap', () => { 74 | describe('AsyncHooksContext', () => { 75 | const Context = new AsyncHooksContext(); 76 | 77 | describe('When context is not configured to tracking', () => { 78 | it('Throws miss configured error', () => { 79 | expect(() => Context.monitor()).toThrow(ExecutionContextErrors.MONITOR_MISS_CONFIGURATION); 80 | }); 81 | }); 82 | 83 | describe('When no context is open', () => { 84 | let report; 85 | beforeEach(() => { 86 | Context.configure({ monitor: true }); 87 | 88 | report = Context.monitor(); 89 | }); 90 | it('Returns empty usage', () => { 91 | expect(report.size).toEqual(0); 92 | }); 93 | 94 | it('Return empty array entries', () => { 95 | expect(Array.isArray(report.entries)).toBeTruthy(); 96 | expect(report.entries).toHaveLength(0); 97 | }); 98 | }); 99 | 100 | describe('When context is created', () => { 101 | const contextAware = (fn) => { 102 | Context.create({ value: true }); 103 | fn(); 104 | }; 105 | 106 | const spwan = () => new Promise((resolve) => setTimeout(resolve, 100)); 107 | 108 | describe('When a single process is present', () => { 109 | it('Reports with empty usage', () => { 110 | contextAware(() => { 111 | const report = Context.monitor(); 112 | 113 | expect(report.size).toEqual(1); 114 | expect(report.entries).toHaveLength(1); 115 | expect(report.entries[0].children).toHaveLength(0); 116 | }); 117 | }); 118 | }); 119 | 120 | describe('When sub process is present', () => { 121 | it('Reports root context entries', (done) => { 122 | contextAware(() => { 123 | spwan(); 124 | const report = Context.monitor(); 125 | 126 | expect(report.size > 0).toBeTruthy(); 127 | expect(report.entries.length > 0).toBeTruthy(); 128 | done(); 129 | }); 130 | }); 131 | }); 132 | }); 133 | }); 134 | }); 135 | }); 136 | -------------------------------------------------------------------------------- /src/lib/types.d.ts: -------------------------------------------------------------------------------- 1 | interface ExecutionMapUsageBaseEntry { 2 | asyncId: number; 3 | created: number; 4 | duration: number; 5 | } 6 | 7 | interface ExecutionMapUsageChildEntry extends ExecutionMapUsageBaseEntry { 8 | type: string; 9 | } 10 | 11 | interface ExecutionMapUsageEntry extends ExecutionMapUsageBaseEntry { 12 | asyncId: number; 13 | domain: string; 14 | children: ExecutionMapUsageChildEntry[]; 15 | } 16 | 17 | interface ExecutionMapUsage { 18 | size: number; 19 | entries: ExecutionMapUsageEntry[]; 20 | } 21 | 22 | export { 23 | ExecutionMapUsage, 24 | ExecutionMapUsageEntry 25 | } 26 | -------------------------------------------------------------------------------- /src/managers/asyncHooks/constants.js: -------------------------------------------------------------------------------- 1 | /** 2 | * The default configuration to use. 3 | * @type {ExecutionContextConfig} 4 | */ 5 | exports.DEFAULT_CONFIG = { 6 | monitor: false 7 | }; 8 | -------------------------------------------------------------------------------- /src/managers/asyncHooks/hooks/constants.js: -------------------------------------------------------------------------------- 1 | /** 2 | * The excluded async process from our context map. 3 | * Connections async processes tend not to be destroyed, which potentially will cause memory leak issues. 4 | * We can skip those types assuming the execution context won't be used for these process types. 5 | * @type {Set} 6 | */ 7 | exports.EXCLUDED_ASYNC_TYPES = new Set([ 8 | 'DNSCHANNEL', 9 | 'TLSWRAP', 10 | 'TCPWRAP', 11 | 'HTTPPARSER', 12 | 'ZLIB', 13 | 'UDPSENDWRAP', 14 | 'UDPWRAP' 15 | ]); 16 | -------------------------------------------------------------------------------- /src/managers/asyncHooks/hooks/index.js: -------------------------------------------------------------------------------- 1 | const { isUndefined } = require('../../../lib'); 2 | const { EXCLUDED_ASYNC_TYPES } = require('./constants'); 3 | 4 | /** 5 | * Returns proper context ref for a given trigger id. 6 | * @param {ExecutionContextNode} parentContext - The parent context triggered the init 7 | * @param {Number} triggerAsyncId - The current triggerAsyncId 8 | */ 9 | const getContextRef = (parentContext, triggerAsyncId) => ( 10 | isUndefined(parentContext.ref) ? triggerAsyncId : parentContext.ref 11 | ); 12 | 13 | /** 14 | * Suspends a given function execution over process next tick. 15 | * @param {Function} fn - The function to trigger upon next tick. 16 | * @param {...any} args - The function arguments to trigger with. 17 | * @return {any} 18 | */ 19 | const suspend = (fn, ...args) => setImmediate(() => fn(...args)); 20 | 21 | /** 22 | * The "async_hooks" init hook callback, used to initialize sub process of the main context 23 | * Processes without any parent context will be ignored. 24 | * @param {ExecutionContextMap} executionContextMap - The execution context map 25 | * @returns init-hook(asyncId: Number, type: String, triggerAsyncId:Number) 26 | */ 27 | const init = (executionContextMap) => (asyncId, type, triggerAsyncId) => { 28 | const parentContext = executionContextMap.get(triggerAsyncId); 29 | 30 | if (!parentContext || EXCLUDED_ASYNC_TYPES.has(type)) return; 31 | 32 | const ref = getContextRef(parentContext, triggerAsyncId); 33 | const refContext = executionContextMap.get(ref); 34 | 35 | // Setting child process entry as ref to parent context 36 | executionContextMap.set(asyncId, { 37 | ref, 38 | ...(refContext.monitor && { 39 | created: Date.now(), 40 | type 41 | }) 42 | }); 43 | 44 | // Adding current async as child to parent context in order to control cleanup better 45 | refContext.children ? refContext.children.push(asyncId) : refContext.children = [asyncId]; 46 | }; 47 | 48 | /** 49 | * Notify a root process on one of it's child destroy event, will eventually cleanup parent context entry 50 | * when there will be no sub processes dependant on it left 51 | * @param {ExecutionContextMap} executionContextMap - The execution context map 52 | * @param {Number} asyncId - The current asyncId of the child process being destroyed 53 | * @param {Number} ref - The parent process ref asyncId 54 | */ 55 | const onChildProcessDestroy = (executionContextMap, asyncId, ref) => { 56 | if (!executionContextMap.has(ref)) return; 57 | 58 | const refContext = executionContextMap.get(ref); 59 | const filtered = refContext.children.filter((id) => id !== asyncId); 60 | 61 | // Parent context will be released upon last child removal 62 | if (!filtered.length) { 63 | suspend(() => executionContextMap.delete(ref)); 64 | 65 | return; 66 | } 67 | 68 | refContext.children = filtered; 69 | }; 70 | 71 | /** 72 | * The "async_hooks" destroy hook callback, used for clean up the execution map. 73 | * @param {ExecutionContextMap} executionContextMap - The execution context map 74 | * @return destroy-hook(asyncId: Number) 75 | */ 76 | const destroy = (executionContextMap) => (asyncId) => { 77 | if (!executionContextMap.has(asyncId)) return; 78 | 79 | const { children = [], ref } = executionContextMap.get(asyncId); 80 | 81 | // As long as any root process holds none finished child process, we keep it alive 82 | if (children.length) { 83 | return; 84 | } 85 | 86 | // Child context's will unregister themselves from root context 87 | if (!isUndefined(ref)) { 88 | suspend( 89 | onChildProcessDestroy, 90 | executionContextMap, 91 | asyncId, 92 | ref 93 | ); 94 | } 95 | 96 | suspend(() => executionContextMap.delete(asyncId)); 97 | }; 98 | 99 | /** 100 | * The Create hooks callback to be passed to "async_hooks" 101 | * @param {ExecutionContextMap} executionContextMap - The execution context map 102 | * @see https://nodejs.org/api/async_hooks.html#async_hooks_async_hooks_createhook_callbacks 103 | * @returns {HookCallbacks} 104 | */ 105 | const create = (executionContextMap) => ({ 106 | init: init(executionContextMap), 107 | destroy: destroy(executionContextMap), 108 | promiseResolve: destroy(executionContextMap) 109 | }); 110 | 111 | module.exports = { create, onChildProcessDestroy }; 112 | -------------------------------------------------------------------------------- /src/managers/asyncHooks/hooks/spec.js: -------------------------------------------------------------------------------- 1 | const asyncHooks = require('async_hooks'); 2 | const { create: createHooks } = require('.'); 3 | 4 | describe('Context / Hooks', () => { 5 | const triggerAsyncId = asyncHooks.executionAsyncId(); 6 | let executionMap; 7 | let spies; 8 | let init; 9 | let destroy; 10 | 11 | const spawn = (trigger) => new Promise((resolve) => setTimeout(() => { 12 | const childAsyncId = asyncHooks.executionAsyncId(); 13 | 14 | init(childAsyncId, 'type', trigger); 15 | resolve(childAsyncId); 16 | })); 17 | 18 | beforeEach(() => { 19 | executionMap = new Map(); 20 | 21 | const callbacks = createHooks(executionMap); 22 | 23 | spies = { 24 | initHook: jest.spyOn(callbacks, 'init'), 25 | destroyHook: jest.spyOn(callbacks, 'destroy'), 26 | contextMapSet: jest.spyOn(executionMap, 'set'), 27 | contextMapDelete: jest.spyOn(executionMap, 'delete'), 28 | }; 29 | 30 | init = callbacks.init; 31 | destroy = callbacks.destroy; 32 | }); 33 | 34 | describe('Init', () => { 35 | describe('When context is not present', () => { 36 | beforeEach(() => init()); 37 | 38 | it('Prevent map entries', (done) => { 39 | expect(executionMap.size).toEqual(0); 40 | 41 | setTimeout(() => { 42 | expect(spies.contextMapSet).toHaveBeenCalledTimes(0); 43 | done(); 44 | }, 100); 45 | }); 46 | }); 47 | 48 | describe('When context is present', () => { 49 | let childAsyncId; 50 | 51 | beforeEach(async() => { 52 | executionMap.set(triggerAsyncId, { 53 | context: { val: 'value' }, 54 | children: [] 55 | }); 56 | 57 | childAsyncId = await spawn(triggerAsyncId); 58 | }); 59 | 60 | it('Register sub process as ref entry under root process', () => { 61 | expect(executionMap.size).toEqual(2); 62 | 63 | expect(executionMap.get(childAsyncId)).toMatchObject({ 64 | ref: triggerAsyncId 65 | }); 66 | }); 67 | 68 | it('Adds sub process to trigger context as children', () => { 69 | const { children: triggerChildren } = executionMap.get(triggerAsyncId); 70 | 71 | expect(triggerChildren.length).toEqual(1); 72 | expect(triggerChildren).toContain(childAsyncId); 73 | }); 74 | 75 | it('Register nested sub process as ref entry under root process', async() => { 76 | const nestedChildAsyncId = await spawn(childAsyncId); 77 | 78 | expect(executionMap.size).toEqual(3); 79 | expect(executionMap.get(nestedChildAsyncId)).toMatchObject({ 80 | ref: triggerAsyncId 81 | }); 82 | 83 | const { children: triggerChildren } = executionMap.get(triggerAsyncId); 84 | expect(triggerChildren.length).toEqual(2); 85 | expect(triggerChildren).toContain(nestedChildAsyncId); 86 | }); 87 | }); 88 | }); 89 | 90 | describe('Destroy', () => { 91 | it('Ignores triggers for non existing entries', () => { 92 | destroy('something'); 93 | expect(spies.contextMapDelete).toHaveBeenCalledTimes(0); 94 | }); 95 | 96 | describe('Parent process entry', () => { 97 | it('Prevents removal when still has running child processes', () => { 98 | executionMap.set(triggerAsyncId, { 99 | context: {}, 100 | children: [3] 101 | }); 102 | 103 | destroy(triggerAsyncId); 104 | 105 | expect(executionMap.get(triggerAsyncId)).toBeDefined(); 106 | expect(spies.contextMapDelete).toHaveBeenCalledTimes(0); 107 | }); 108 | 109 | it('Removes parent when no children depends on it', (done) => { 110 | executionMap.set(triggerAsyncId, { 111 | context: {}, 112 | children: [] 113 | }); 114 | 115 | destroy(triggerAsyncId); 116 | 117 | setImmediate(() => { 118 | expect(executionMap.get(triggerAsyncId)).toBeUndefined(); 119 | expect(spies.contextMapDelete).toHaveBeenCalledWith(triggerAsyncId); 120 | 121 | done(); 122 | }); 123 | }); 124 | }); 125 | 126 | describe('Child process entry', () => { 127 | let children; 128 | beforeEach(async() => { 129 | executionMap.set(triggerAsyncId, { 130 | context: {}, 131 | children: [] 132 | }); 133 | 134 | children = [ 135 | await spawn(triggerAsyncId), 136 | await spawn(triggerAsyncId) 137 | ]; 138 | 139 | }); 140 | 141 | it('Removes child', (done) => { 142 | const [ firstChild ] = children; 143 | destroy(firstChild); 144 | 145 | setImmediate(() => { 146 | expect(spies.contextMapDelete).toHaveBeenCalledWith(firstChild); 147 | 148 | done(); 149 | }); 150 | }); 151 | 152 | it('Triggers parent cleanup when all child process died', (done) => { 153 | children.forEach(destroy); 154 | 155 | setTimeout(() => { 156 | expect(executionMap.size).toEqual(0); 157 | 158 | done(); 159 | }, 200); 160 | }); 161 | }); 162 | }); 163 | }); 164 | -------------------------------------------------------------------------------- /src/managers/asyncHooks/hooks/types.d.ts: -------------------------------------------------------------------------------- 1 | interface HookCallbacks { 2 | 3 | /** 4 | * Called when a class is constructed that has the possibility to emit an asynchronous event. 5 | * @param asyncId a unique ID for the async resource 6 | * @param type the type of the async resource 7 | * @param triggerAsyncId the unique ID of the async resource in whose execution context this async resource was created 8 | * @param resource reference to the resource representing the async operation, needs to be released during destroy 9 | */ 10 | init(asyncId: number, type: string, triggerAsyncId: number, resource: object): void; 11 | 12 | /** 13 | * Called when a promise has resolve() called. This may not be in the same execution id 14 | * as the promise itself. 15 | * @param asyncId the unique id for the promise that was resolve()d. 16 | */ 17 | promiseResolve(asyncId: number): void; 18 | 19 | /** 20 | * Called after the resource corresponding to asyncId is destroyed 21 | * @param asyncId a unique ID for the async resource 22 | */ 23 | destroy(asyncId: number): void; 24 | } 25 | 26 | export { 27 | HookCallbacks 28 | } 29 | -------------------------------------------------------------------------------- /src/managers/asyncHooks/index.js: -------------------------------------------------------------------------------- 1 | const asyncHooks = require('async_hooks'); 2 | const { monitorMap, handleError, isObject, ExecutionContextResource } = require('../../lib'); 3 | const { create: createHooks, onChildProcessDestroy } = require('./hooks'); 4 | const { ExecutionContextErrors } = require('../../ExecutionContext/constants'); 5 | const { DEFAULT_CONFIG } = require('./constants'); 6 | 7 | /** 8 | * The execution context maps which acts as the execution context in memory storage. 9 | * @see ExecutionContext.monitor 10 | * @type ExecutionContextMap 11 | */ 12 | const executionContextMap = new Map(); 13 | 14 | /** 15 | * Creates a root execution context node. 16 | * @param {Number} asyncId - The current async id. 17 | * @param {*} context - The initial context ro provide this execution chain. 18 | * @param {Boolean} monitor - Whether to monitor current root node or not. 19 | * @return {ExecutionContextNode} 20 | */ 21 | const createRootContext = ({ asyncId, context, monitor }) => ({ 22 | asyncId, 23 | context, 24 | children: [], 25 | monitor, 26 | ...(monitor && { created: Date.now() }) 27 | }); 28 | 29 | /** 30 | * @type {ExecutionContextAPI} 31 | */ 32 | class AsyncHooksContext { 33 | constructor() { 34 | this.config = { ...DEFAULT_CONFIG }; 35 | 36 | // Sets node async hooks setup 37 | asyncHooks.createHook( 38 | createHooks(executionContextMap) 39 | ).enable(); 40 | } 41 | 42 | /** 43 | * Returns current execution id root context for the current asyncId process. 44 | * @param {Number} asyncId - The current execution context id. 45 | * @return {ExecutionContextNode} 46 | * @private 47 | */ 48 | _getRootContext(asyncId) { 49 | const context = executionContextMap.get(asyncId); 50 | 51 | if (context && context.ref) return executionContextMap.get(context.ref); 52 | 53 | return context; 54 | } 55 | 56 | create(context) { 57 | const config = this.config; 58 | const asyncId = asyncHooks.executionAsyncId(); 59 | 60 | const rootContext = this._getRootContext(asyncId); 61 | if (rootContext) { 62 | 63 | // Disconnecting current async id from stored parent chain 64 | onChildProcessDestroy(executionContextMap, asyncId, rootContext.asyncId); 65 | } 66 | 67 | // Creating root context node 68 | const root = createRootContext({ 69 | asyncId, 70 | context, 71 | monitor: config.monitor 72 | }); 73 | 74 | executionContextMap.set(asyncId, root); 75 | } 76 | 77 | run(fn, context) { 78 | const resource = new ExecutionContextResource(); 79 | 80 | resource.runInAsyncScope(() => { 81 | this.create(context); 82 | 83 | fn(); 84 | }); 85 | } 86 | 87 | exists() { 88 | const asyncId = asyncHooks.executionAsyncId(); 89 | 90 | return executionContextMap.has(asyncId); 91 | } 92 | 93 | get() { 94 | const asyncId = asyncHooks.executionAsyncId(); 95 | 96 | if (!executionContextMap.has(asyncId)) return handleError(ExecutionContextErrors.CONTEXT_DOES_NOT_EXIST); 97 | 98 | return this._getRootContext(asyncId).context; 99 | } 100 | 101 | set(context) { 102 | const asyncId = asyncHooks.executionAsyncId(); 103 | 104 | if (!executionContextMap.has(asyncId)) return handleError(ExecutionContextErrors.CONTEXT_DOES_NOT_EXIST); 105 | 106 | // Update target is always the root context, ref updates will need to be channeled 107 | const rootContext = this._getRootContext(asyncId); 108 | 109 | rootContext.context = context; 110 | } 111 | 112 | update(context) { 113 | const asyncId = asyncHooks.executionAsyncId(); 114 | 115 | if (!executionContextMap.has(asyncId)) return handleError(ExecutionContextErrors.CONTEXT_DOES_NOT_EXIST); 116 | if (!isObject(context)) return handleError(ExecutionContextErrors.UPDATE_BLOCKED); 117 | 118 | // Update target is always the root context, ref updates will need to be channeled 119 | const rootContext = this._getRootContext(asyncId); 120 | 121 | Object.assign(rootContext.context, context); 122 | } 123 | 124 | configure(config) { 125 | this.config = config; 126 | } 127 | 128 | monitor() { 129 | if (!this.config.monitor) { 130 | throw new Error(ExecutionContextErrors.MONITOR_MISS_CONFIGURATION); 131 | } 132 | 133 | return monitorMap(executionContextMap); 134 | } 135 | } 136 | 137 | module.exports = AsyncHooksContext; 138 | -------------------------------------------------------------------------------- /src/managers/asyncHooks/spec.js: -------------------------------------------------------------------------------- 1 | const asyncHooks = require('async_hooks'); 2 | const hooks = require('./hooks'); 3 | 4 | describe('AsyncHooksContext', () => { 5 | beforeEach(() => { 6 | const ExecutionContext = jest.requireActual('.'); 7 | new ExecutionContext(); 8 | }); 9 | 10 | describe('Initialise node "async_hooks"', () => { 11 | const spies = { 12 | asyncHooksCreate: jest.spyOn(asyncHooks, 'createHook'), 13 | hooksCreate: jest.spyOn(hooks, 'create') 14 | }; 15 | 16 | it('Trigger "async_hooks" create', () => { 17 | expect(spies.asyncHooksCreate).toHaveBeenCalled(); 18 | }); 19 | 20 | it('Uses context hooks create', () => { 21 | expect(spies.hooksCreate).toHaveBeenCalled(); 22 | }); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/managers/asyncLocalStorage/constants.js: -------------------------------------------------------------------------------- 1 | /** 2 | * The deprecation messages to use when deprecated functions are being called. 3 | * @type {Object} 4 | */ 5 | exports.DEPRECATION_MESSAGES = { 6 | CONFIGURE: 'Configure is relevant only for AsyncHooks context manager and should not be used on this current node version.', 7 | MONITOR: 'Monitoring is relevant only for AsyncHooks context manager since the contexts are self managed. This version implementation is based on nodejs `AsyncLocalStorage` feature and thus guaranteed to have no memory issues.' 8 | }; 9 | -------------------------------------------------------------------------------- /src/managers/asyncLocalStorage/index.js: -------------------------------------------------------------------------------- 1 | const { AsyncLocalStorage } = require('async_hooks'); 2 | const { handleError, isUndefined, isObject } = require('../../lib'); 3 | const { ExecutionContextErrors } = require('../../ExecutionContext/constants'); 4 | const { DEPRECATION_MESSAGES } = require('./constants'); 5 | 6 | /** 7 | * Check whether a given async local storage has a valid store. 8 | * @param {AsyncLocalStorage} asyncLocalStorage - The async local storage to validate it's store presence 9 | * @return {Boolean} 10 | */ 11 | const validateStore = (asyncLocalStorage) => !isUndefined( 12 | asyncLocalStorage.getStore() 13 | ); 14 | 15 | /** 16 | * @class {ExecutionContextAPI} 17 | */ 18 | class AsyncLocalStorageContext { 19 | constructor() { 20 | this.asyncLocaleStorage = new AsyncLocalStorage(); 21 | } 22 | 23 | create(context){ 24 | this.asyncLocaleStorage.enterWith({ context }); 25 | } 26 | 27 | run(fn, context) { 28 | return this.asyncLocaleStorage.run( 29 | { context }, 30 | fn 31 | ); 32 | } 33 | 34 | exists() { 35 | return validateStore(this.asyncLocaleStorage); 36 | } 37 | 38 | get() { 39 | if (!validateStore(this.asyncLocaleStorage)) { 40 | return handleError(ExecutionContextErrors.CONTEXT_DOES_NOT_EXIST); 41 | } 42 | 43 | const { context } = this.asyncLocaleStorage.getStore(); 44 | 45 | return context; 46 | } 47 | 48 | set(context) { 49 | if (!validateStore(this.asyncLocaleStorage)) { 50 | return handleError(ExecutionContextErrors.CONTEXT_DOES_NOT_EXIST); 51 | } 52 | 53 | this.asyncLocaleStorage.getStore().context = context; 54 | } 55 | 56 | update(context) { 57 | if (!validateStore(this.asyncLocaleStorage)) { 58 | return handleError(ExecutionContextErrors.CONTEXT_DOES_NOT_EXIST); 59 | } 60 | 61 | if (!isObject(context)) { 62 | return handleError(ExecutionContextErrors.UPDATE_BLOCKED); 63 | } 64 | 65 | const current = this.asyncLocaleStorage.getStore().context; 66 | 67 | Object.assign(current, context); 68 | } 69 | 70 | configure() { 71 | console.warn(DEPRECATION_MESSAGES.CONFIGURE); // eslint-disable-line no-console 72 | } 73 | 74 | monitor() { 75 | console.warn(DEPRECATION_MESSAGES.MONITOR); // eslint-disable-line no-console 76 | } 77 | } 78 | 79 | module.exports = AsyncLocalStorageContext; 80 | -------------------------------------------------------------------------------- /src/managers/asyncLocalStorage/spec.js: -------------------------------------------------------------------------------- 1 | const { supportAsyncLocalStorage } = require('../../lib'); 2 | const AsyncLocalStorageContext = require('.'); 3 | 4 | /** 5 | * Runs given assertion only in case "AsyncLocalStorage" feature supported by current running node. 6 | * @param {Function} assertion - The assertion to call. 7 | * @return {Boolean} 8 | */ 9 | const safeAssert = (assertion) => supportAsyncLocalStorage() && assertion(); 10 | 11 | describe('AsyncLocalStorageContext', () => { 12 | let Context; 13 | 14 | afterEach(jest.clearAllMocks); 15 | 16 | describe.each([ 17 | 'monitor', 18 | 'configure' 19 | ])('Should warn about usage', (apiName) => { 20 | const shouldValidate = supportAsyncLocalStorage(); 21 | 22 | let spiesWarn; 23 | let result; 24 | 25 | const setup = () => { 26 | spiesWarn = jest.spyOn(console, 'warn'); 27 | Context = shouldValidate && new AsyncLocalStorageContext(); 28 | result = shouldValidate ? Context[apiName]() : undefined; 29 | }; 30 | 31 | beforeEach(() => { 32 | shouldValidate && setup(); 33 | }); 34 | 35 | it('Should warn about monitoring usage', () => { 36 | safeAssert(() => expect(spiesWarn).toHaveBeenCalledTimes(1)); 37 | }); 38 | 39 | it('Returns undefined', () => { 40 | safeAssert(() => expect(result).toBeUndefined()); 41 | }); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/managers/index.js: -------------------------------------------------------------------------------- 1 | const AsyncHooksContext = require('./asyncHooks'); 2 | const AsyncLocalStorageContext = require('./asyncLocalStorage'); 3 | 4 | module.exports = { 5 | AsyncLocalStorageContext, 6 | AsyncHooksContext 7 | }; 8 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionMapUsage, ExecutionMapUsageEntry } from './lib/types'; 2 | import { HookCallbacks } from './managers/asyncHooks/hooks/types'; 3 | import { ExecutionContextConfig, ExecutionContextNode, ExecutionContextMap } from './ExecutionContext/types'; 4 | 5 | interface ExecutionContextAPI { 6 | 7 | /** 8 | * Creates a given context for the current asynchronous execution. 9 | * Note that if this method will be called not within a AsyncResource context, it will effect current execution context. 10 | * @param context - The context to expose. 11 | */ 12 | create(context: unknown): void; 13 | 14 | /** 15 | * Sets the current asynchronous execution context to given value. 16 | * @param context - The new context to expose current asynchronous execution. 17 | */ 18 | set(context: unknown): void; 19 | 20 | /** 21 | * Updates the current asynchronous execution context with a given value. 22 | * @param context - The value to update. 23 | */ 24 | update(context: Record): void; 25 | 26 | /** 27 | * Checks if the current asynchronous execution context exists. 28 | */ 29 | exists(): boolean; 30 | 31 | /** 32 | * Gets the current asynchronous execution context. 33 | */ 34 | get(): T; 35 | 36 | /** 37 | * Runs given callback that will be exposed to the given context. 38 | * This will expose the given context within all callbacks and promise chains that will be called from given fn. 39 | * @param fn - The function to run. 40 | * @param context - The context to expose. 41 | */ 42 | run(fn: () => T, context: unknown): T; 43 | 44 | /** 45 | * Configures current execution context manager. 46 | * [Note] Relevant for node v12.17.0 and below 47 | * @param config - the configuration to use. 48 | */ 49 | config(config: ExecutionContextConfig): void; 50 | 51 | /** 52 | * Monitors current execution map usage 53 | * [Note] Relevant for node v12.17.0 and below 54 | * @return {ExecutionMapUsage|undefined} 55 | */ 56 | monitor(): ExecutionMapUsage | undefined; 57 | } 58 | 59 | declare const context: ExecutionContextAPI; 60 | 61 | export { 62 | HookCallbacks, 63 | ExecutionMapUsageEntry, 64 | ExecutionMapUsage, 65 | ExecutionContextMap, 66 | ExecutionContextNode, 67 | ExecutionContextConfig, 68 | ExecutionContextAPI 69 | }; 70 | 71 | export default context; 72 | --------------------------------------------------------------------------------