From 9bd4afbe7e7e7c21b62de1cda02c096743194e49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= <mbajtos@cz.ibm.com> Date: Thu, 28 Jul 2016 15:40:19 +0200 Subject: [PATCH 1/4] Initial implementation --- README.md | 30 +++++++ browser/current-context.js | 17 ++++ package.json | 13 ++- server/current-context.js | 85 +++++++++++++++++++ server/middleware/per-request-context.js | 59 +++++++++++++ test/helpers/expect.js | 6 ++ test/main.test.js | 100 +++++++++++++++++++++++ 7 files changed, 306 insertions(+), 4 deletions(-) create mode 100644 browser/current-context.js create mode 100644 server/current-context.js create mode 100644 server/middleware/per-request-context.js create mode 100644 test/helpers/expect.js create mode 100644 test/main.test.js diff --git a/README.md b/README.md index 5162c27..e4ce733 100644 --- a/README.md +++ b/README.md @@ -2,3 +2,33 @@ Current context for LoopBack applications, based on node-continuation-local-storage. + +## Usage + +1) Add `per-request-context` middleware to your +`server/middleware-config.json`: + +```json +{ + "initial": { + "loopback-context-cls#per-request-context": { + } + } +} +``` + +2) Then you can access the context from your code: + +```js +var ClsContext = require('loopback-context-cls'); + +// ... + +MyModel.myMethod = function(cb) { + var ctx = ClsContext.getCurrentContext(); + ctx.get('key'); + ctx.set('key', { foo: 'bar' }); +}); +``` + +See also https://docs.strongloop.com/display/APIC/Using+current+context diff --git a/browser/current-context.js b/browser/current-context.js new file mode 100644 index 0000000..5446219 --- /dev/null +++ b/browser/current-context.js @@ -0,0 +1,17 @@ +// Copyright IBM Corp. 2015. All Rights Reserved. +// Node module: loopback-context-cls +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +'use strict'; + +var ClsContext = module.exports; + +ClsContext.getCurrentContext = function() { + return null; +}; + +ClsContext.runInContext = +ClsContext.createContext = function() { + throw new Error('Current context is not supported in the browser.'); +}; diff --git a/package.json b/package.json index a7b80fb..c4f28ae 100644 --- a/package.json +++ b/package.json @@ -11,19 +11,24 @@ "type": "git", "url": "https://github.com/strongloop/loopback-context" }, - "main": "index.js", - "browser": "browser.js", + "main": "server/current-context.js", + "browser": "browser/current-context.js", "scripts": { "test": "mocha", "posttest": "npm run lint", "lint": "eslint ." }, "license": "MIT", - "dependencies": {}, + "dependencies": { + "continuation-local-storage": "^3.1.7" + }, "devDependencies": { + "chai": "^3.5.0", + "dirty-chai": "^1.2.2", "eslint": "^2.13.1", "eslint-config-loopback": "^4.0.0", "loopback": "^3.0.0-alpha.1", - "mocha": "^2.5.3" + "mocha": "^2.5.3", + "supertest": "^1.2.0" } } diff --git a/server/current-context.js b/server/current-context.js new file mode 100644 index 0000000..26088ae --- /dev/null +++ b/server/current-context.js @@ -0,0 +1,85 @@ +// Copyright IBM Corp. 2015,2016. All Rights Reserved. +// Node module: loopback-context-cls +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +'use strict'; + +var cls = require('continuation-local-storage'); +var domain = require('domain'); + +var ClsContext = module.exports; + +/** + * Get the current context object. The context is preserved + * across async calls, it behaves like a thread-local storage. + * + * @returns {Namespace} The context object or null. + */ +ClsContext.getCurrentContext = function() { + // A placeholder method, see CurrentContext.createContext() for the real version + return null; +}; + +/** + * Run the given function in such way that + * `CurrentContext.getCurrentContext` returns the + * provided context object. + * + * **NOTE** + * + * The method is supported on the server only, it does not work + * in the browser at the moment. + * + * @param {Function} fn The function to run, it will receive arguments + * (currentContext, currentDomain). + * @param {Namespace} context An optional context object. + * When no value is provided, then the default global context is used. + */ +ClsContext.runInContext = function(fn, context) { + var currentDomain = domain.create(); + currentDomain.oldBind = currentDomain.bind; + currentDomain.bind = function(callback, context) { + return currentDomain.oldBind(ns.bind(callback, context), context); + }; + + var ns = context || ClsContext.createContext('loopback'); + + currentDomain.run(function() { + ns.run(function executeInContext(context) { + fn(ns, currentDomain); + }); + }); +}; + +/** + * Create a new LoopBackContext instance that can be used + * for `CurrentContext.runInContext`. + * + * **NOTES** + * + * At the moment, `CurrentContext.getCurrentContext` supports + * a single global context instance only. If you call `createContext()` + * multiple times, `getCurrentContext` will return the last context + * created. + * + * The method is supported on the server only, it does not work + * in the browser at the moment. + * + * @param {String} scopeName An optional scope name. + * @return {Namespace} The new context object. + */ +ClsContext.createContext = function(scopeName) { + // Make the namespace globally visible via the process.context property + process.context = process.context || {}; + var ns = process.context[scopeName]; + if (!ns) { + ns = cls.createNamespace(scopeName); + process.context[scopeName] = ns; + // Set up CurrentContext.getCurrentContext() + ClsContext.getCurrentContext = function() { + return ns && ns.active ? ns : null; + }; + } + return ns; +}; diff --git a/server/middleware/per-request-context.js b/server/middleware/per-request-context.js new file mode 100644 index 0000000..1b8a1b7 --- /dev/null +++ b/server/middleware/per-request-context.js @@ -0,0 +1,59 @@ +// Copyright IBM Corp. 2014,2016. All Rights Reserved. +// Node module: loopback-context-cls +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +'use strict'; + +var ClsContext = require('../current-context'); + +module.exports = context; + +var name = 'loopback'; + +/** + * Context middleware. + * ```js + * var app = loopback(); + * app.use(loopback.context(options); + * app.use(loopback.rest()); + * app.listen(); + * ``` + * @options {Object} [options] Options for context + * @property {String} name Context scope name. + * @property {Boolean} enableHttpContext Whether HTTP context is enabled. Default is false. + * @header loopback.context([options]) + */ + +function context(options) { + options = options || {}; + var scope = options.name || name; + var enableHttpContext = options.enableHttpContext || false; + var ns = ClsContext.createContext(scope); + + // Return the middleware + return function contextHandler(req, res, next) { + if (req.loopbackContext) { + return next(); + } + + ClsContext.runInContext(function processRequestInContext(ns, domain) { + req.loopbackContext = ns; + + // Bind req/res event emitters to the given namespace + ns.bindEmitter(req); + ns.bindEmitter(res); + + // Add req/res event emitters to the current domain + domain.add(req); + domain.add(res); + + // Run the code in the context of the namespace + if (enableHttpContext) { + // Set up the transport context + ns.set('http', {req: req, res: res}); + } + next(); + }); + }; +} diff --git a/test/helpers/expect.js b/test/helpers/expect.js new file mode 100644 index 0000000..830d8aa --- /dev/null +++ b/test/helpers/expect.js @@ -0,0 +1,6 @@ +'use strict'; + +var chai = require('chai'); +chai.use(require('dirty-chai')); + +module.exports = chai.expect; diff --git a/test/main.test.js b/test/main.test.js new file mode 100644 index 0000000..5e59862 --- /dev/null +++ b/test/main.test.js @@ -0,0 +1,100 @@ +// Copyright IBM Corp. 2013,2016. All Rights Reserved. +// Node module: loopback-context-cls +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +'use strict'; + +var ClsContext = require('..'); +var Domain = require('domain'); +var EventEmitter = require('events').EventEmitter; +var expect = require('./helpers/expect'); +var loopback = require('loopback'); +var request = require('supertest'); + +describe('CLS Context', function() { + var runInOtherDomain, runnerInterval; + + before(function setupRunInOtherDomain() { + var emitterInOtherDomain = new EventEmitter(); + Domain.create().add(emitterInOtherDomain); + + runInOtherDomain = function(fn) { + emitterInOtherDomain.once('run', fn); + }; + + runnerInterval = setInterval(function() { + emitterInOtherDomain.emit('run'); + }, 10); + }); + + after(function tearDownRunInOtherDomain() { + clearInterval(runnerInterval); + }); + + // See the following two items for more details: + // https://github.com/strongloop/loopback/issues/809 + // https://github.com/strongloop/loopback/pull/337#issuecomment-61680577 + it('preserves callback domain', function(done) { + var app = loopback({localRegistry: true, loadBuiltinModels: true}); + app.set('remoting', {context: false}); + app.use(require('../server/middleware/per-request-context')()); + app.use(loopback.rest()); + app.dataSource('db', {connector: 'memory'}); + + var TestModel = loopback.createModel({name: 'TestModel'}); + app.model(TestModel, {dataSource: 'db', public: true}); + + // function for remote method + TestModel.test = function(inst, cb) { + var tmpCtx = ClsContext.getCurrentContext(); + if (tmpCtx) tmpCtx.set('data', 'a value stored in context'); + if (process.domain) cb = process.domain.bind(cb); // IMPORTANT + runInOtherDomain(cb); + }; + + // remote method + TestModel.remoteMethod('test', { + accepts: {arg: 'inst', type: 'TestModel'}, + returns: {root: true}, + http: {path: '/test', verb: 'get'}, + }); + + // after remote hook + TestModel.afterRemote('**', function(ctxx, inst, next) { + var tmpCtx = ClsContext.getCurrentContext(); + if (tmpCtx) { + ctxx.result.data = tmpCtx.get('data'); + } else { + ctxx.result.data = 'context not available'; + } + + next(); + }); + + request(app) + .get('/TestModels/test') + .end(function(err, res) { + if (err) return done(err); + + expect(res.body.data).to.equal('a value stored in context'); + + done(); + }); + }); + + it('works outside REST middleware', function(done) { + ClsContext.runInContext(function() { + var ctx = ClsContext.getCurrentContext(); + expect(ctx).is.an('object'); + ctx.set('test-key', 'test-value'); + process.nextTick(function() { + var ctx = ClsContext.getCurrentContext(); + expect(ctx).is.an('object'); + expect(ctx.get('test-key')).to.equal('test-value'); + + done(); + }); + }); + }); +}); From 867a7c3616dc0567761bf70a6b296f31c0b68b0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= <mbajtos@cz.ibm.com> Date: Fri, 29 Jul 2016 09:29:10 +0200 Subject: [PATCH 2/4] Rename to loopback-context/LoopBackContext --- README.md | 8 ++++---- browser/current-context.js | 8 ++++---- server/current-context.js | 22 +++++++++++----------- server/middleware/per-request-context.js | 13 +++++++------ test/main.test.js | 3 ++- 5 files changed, 28 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index e4ce733..9572750 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# loopback-context-cls +# loopback-context Current context for LoopBack applications, based on node-continuation-local-storage. @@ -11,7 +11,7 @@ node-continuation-local-storage. ```json { "initial": { - "loopback-context-cls#per-request-context": { + "loopback-context#per-request-context": { } } } @@ -20,12 +20,12 @@ node-continuation-local-storage. 2) Then you can access the context from your code: ```js -var ClsContext = require('loopback-context-cls'); +var LoopBackContext = require('loopback-context'); // ... MyModel.myMethod = function(cb) { - var ctx = ClsContext.getCurrentContext(); + var ctx = LoopBackContext.getCurrentContext(); ctx.get('key'); ctx.set('key', { foo: 'bar' }); }); diff --git a/browser/current-context.js b/browser/current-context.js index 5446219..955fbf8 100644 --- a/browser/current-context.js +++ b/browser/current-context.js @@ -5,13 +5,13 @@ 'use strict'; -var ClsContext = module.exports; +var LoopBackContext = module.exports; -ClsContext.getCurrentContext = function() { +LoopBackContext.getCurrentContext = function() { return null; }; -ClsContext.runInContext = -ClsContext.createContext = function() { +LoopBackContext.runInContext = +LoopBackContext.createContext = function() { throw new Error('Current context is not supported in the browser.'); }; diff --git a/server/current-context.js b/server/current-context.js index 26088ae..19fcb93 100644 --- a/server/current-context.js +++ b/server/current-context.js @@ -8,7 +8,7 @@ var cls = require('continuation-local-storage'); var domain = require('domain'); -var ClsContext = module.exports; +var LoopBackContext = module.exports; /** * Get the current context object. The context is preserved @@ -16,14 +16,14 @@ var ClsContext = module.exports; * * @returns {Namespace} The context object or null. */ -ClsContext.getCurrentContext = function() { - // A placeholder method, see CurrentContext.createContext() for the real version +LoopBackContext.getCurrentContext = function() { + // A placeholder method, see LoopBackContext.createContext() for the real version return null; }; /** * Run the given function in such way that - * `CurrentContext.getCurrentContext` returns the + * `LoopBackContext.getCurrentContext` returns the * provided context object. * * **NOTE** @@ -36,14 +36,14 @@ ClsContext.getCurrentContext = function() { * @param {Namespace} context An optional context object. * When no value is provided, then the default global context is used. */ -ClsContext.runInContext = function(fn, context) { +LoopBackContext.runInContext = function(fn, context) { var currentDomain = domain.create(); currentDomain.oldBind = currentDomain.bind; currentDomain.bind = function(callback, context) { return currentDomain.oldBind(ns.bind(callback, context), context); }; - var ns = context || ClsContext.createContext('loopback'); + var ns = context || LoopBackContext.createContext('loopback'); currentDomain.run(function() { ns.run(function executeInContext(context) { @@ -54,11 +54,11 @@ ClsContext.runInContext = function(fn, context) { /** * Create a new LoopBackContext instance that can be used - * for `CurrentContext.runInContext`. + * for `LoopBackContext.runInContext`. * * **NOTES** * - * At the moment, `CurrentContext.getCurrentContext` supports + * At the moment, `LoopBackContext.getCurrentContext` supports * a single global context instance only. If you call `createContext()` * multiple times, `getCurrentContext` will return the last context * created. @@ -69,15 +69,15 @@ ClsContext.runInContext = function(fn, context) { * @param {String} scopeName An optional scope name. * @return {Namespace} The new context object. */ -ClsContext.createContext = function(scopeName) { +LoopBackContext.createContext = function(scopeName) { // Make the namespace globally visible via the process.context property process.context = process.context || {}; var ns = process.context[scopeName]; if (!ns) { ns = cls.createNamespace(scopeName); process.context[scopeName] = ns; - // Set up CurrentContext.getCurrentContext() - ClsContext.getCurrentContext = function() { + // Set up LoopBackContext.getCurrentContext() + LoopBackContext.getCurrentContext = function() { return ns && ns.active ? ns : null; }; } diff --git a/server/middleware/per-request-context.js b/server/middleware/per-request-context.js index 1b8a1b7..4e8753c 100644 --- a/server/middleware/per-request-context.js +++ b/server/middleware/per-request-context.js @@ -5,7 +5,7 @@ 'use strict'; -var ClsContext = require('../current-context'); +var LoopBackContext = require('../current-context'); module.exports = context; @@ -14,22 +14,23 @@ var name = 'loopback'; /** * Context middleware. * ```js + * var perRequestContext = require( + * 'loopback-context/server/middleware/per-request-context.js'); * var app = loopback(); - * app.use(loopback.context(options); + * app.use(perRequestContext(options); * app.use(loopback.rest()); * app.listen(); * ``` * @options {Object} [options] Options for context * @property {String} name Context scope name. - * @property {Boolean} enableHttpContext Whether HTTP context is enabled. Default is false. - * @header loopback.context([options]) + * @property {Boolean} enableHttpContext Whether HTTP context is enabled. Default is false. */ function context(options) { options = options || {}; var scope = options.name || name; var enableHttpContext = options.enableHttpContext || false; - var ns = ClsContext.createContext(scope); + var ns = LoopBackContext.createContext(scope); // Return the middleware return function contextHandler(req, res, next) { @@ -37,7 +38,7 @@ function context(options) { return next(); } - ClsContext.runInContext(function processRequestInContext(ns, domain) { + LoopBackContext.runInContext(function processRequestInContext(ns, domain) { req.loopbackContext = ns; // Bind req/res event emitters to the given namespace diff --git a/test/main.test.js b/test/main.test.js index 5e59862..11e8f30 100644 --- a/test/main.test.js +++ b/test/main.test.js @@ -12,7 +12,7 @@ var expect = require('./helpers/expect'); var loopback = require('loopback'); var request = require('supertest'); -describe('CLS Context', function() { +describe('LoopBack Context', function() { var runInOtherDomain, runnerInterval; before(function setupRunInOtherDomain() { @@ -38,6 +38,7 @@ describe('CLS Context', function() { it('preserves callback domain', function(done) { var app = loopback({localRegistry: true, loadBuiltinModels: true}); app.set('remoting', {context: false}); + app.set('legacyExplorer', false); app.use(require('../server/middleware/per-request-context')()); app.use(loopback.rest()); app.dataSource('db', {connector: 'memory'}); From 1f214bf3baef33f643779f5f19304734b919943b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= <mbajtos@cz.ibm.com> Date: Fri, 29 Jul 2016 09:32:25 +0200 Subject: [PATCH 3/4] Lazy-load CLS module As soon as CLS module is loaded, the instrumentation/patching of async-listener is fired. This may cause stack overflows due to promise instrumentation. By loading CLS module lazily (only when used for real), we avoid this kind of problems in applications that are not using current-context at all. --- server/current-context.js | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/server/current-context.js b/server/current-context.js index 19fcb93..8e6ae13 100644 --- a/server/current-context.js +++ b/server/current-context.js @@ -5,9 +5,21 @@ 'use strict'; -var cls = require('continuation-local-storage'); var domain = require('domain'); + +// Require CLS only when using the current context feature. +// As soon as this require is done, all the instrumentation/patching +// of async-listener is fired which is not ideal. +// +// Some users observed stack overflows due to promise instrumentation +// and other people have seen similar things: +// https://github.com/othiym23/async-listener/issues/57 +// It all goes away when instrumentation is disabled. +var cls = function() { + return require('continuation-local-storage'); +}; + var LoopBackContext = module.exports; /** @@ -74,7 +86,7 @@ LoopBackContext.createContext = function(scopeName) { process.context = process.context || {}; var ns = process.context[scopeName]; if (!ns) { - ns = cls.createNamespace(scopeName); + ns = cls().createNamespace(scopeName); process.context[scopeName] = ns; // Set up LoopBackContext.getCurrentContext() LoopBackContext.getCurrentContext = function() { From 71585a2395835ec94f0826af27bc470d0c64fc67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= <mbajtos@cz.ibm.com> Date: Fri, 29 Jul 2016 09:39:38 +0200 Subject: [PATCH 4/4] fixup! style --- server/current-context.js | 1 - 1 file changed, 1 deletion(-) diff --git a/server/current-context.js b/server/current-context.js index 8e6ae13..733a86a 100644 --- a/server/current-context.js +++ b/server/current-context.js @@ -7,7 +7,6 @@ var domain = require('domain'); - // Require CLS only when using the current context feature. // As soon as this require is done, all the instrumentation/patching // of async-listener is fired which is not ideal.