├── .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 |
--------------------------------------------------------------------------------