diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..ecf6d68 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,7 @@ +sudo: false +language: node_js +node_js: + - "0.10" + - "0.12" + - "4" + - "6" diff --git a/CHANGES.md b/CHANGES.md new file mode 100644 index 0000000..6f87784 --- /dev/null +++ b/CHANGES.md @@ -0,0 +1,9 @@ +2016-08-10, Version 1.0.0 +========================= + + * First release! + +2016-08-10, Version 2.0.0-alpha.1 +========================= + +* Edited README.md and package.json in order to address both issues loopback-context#9 and async-listener#57 by permanently replacing continuation-local-storage with cls-hooked diff --git a/README.md b/README.md index 5162c27..dfb3d1a 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,86 @@ -# loopback-context-cls +# loopback-context Current context for LoopBack applications, based on -node-continuation-local-storage. +cls-hooked. + +## USAGE WARNING + +**Only if you use this package, do NOT run your app using `slc run` or `node .`** + +Run using: + +`node -r cls-hooked .` + +This uses the `-r` option in order to require `cls-hooked` before your app (see warnings below for more info). + +If you wish to use `strong-supervisor`, you would need to pass node options to `slc run`, which currently has issues, according to [strong-supervisor#56](https://github.com/strongloop/strong-supervisor/issues/56). + +## INSTALL WARNING + +**Only if you use this package, do NOT install your app using `npm install`.** + +Install using: + +``` +npm config set engine-strict true +npm install +``` + +This keeps you from using Node < v0.4.5. + +## TEST WARNING + +**Do NOT test this package using `mocha .`.** + +Test using: + +``` +npm test +``` + +This adds the `-r` option to `mocha` command, needed in order to pass tests. + +## WARNING + +**We recommend AGAINST using the loopback-context module until there is a stable solution to the issue below!** + +The module node-continuation-local-storage is known to have many problems, +see e.g. [issue #59](https://github.com/othiym23/node-continuation-local-storage/issues/59). +As a result, loopback-context does not work in many situations, as can be +seen from issues reported in LoopBack's +[issue tracker](https://github.com/strongloop/loopback/issues?utf8=%E2%9C%93&q=is%3Aissue%20getCurrentcontext). + +The new alternative +[cls-hooked](https://github.com/Jeff-Lewis/cls-hooked) is known to possibly inherit these problems if it's not imported before everything else, that's why you are required to follow the advice above if using this. + +## Usage + +1) Add `per-request` middleware to your +`server/middleware-config.json`: + +```json +{ + "initial": { + "loopback-context#per-request": { + } + } +} +``` + +2) Then you can access the context from your code: + +```js +var LoopBackContext = require('loopback-context'); + +// ... + +MyModel.myMethod = function(cb) { + var ctx = LoopBackContext.getCurrentContext(); + ctx.get('key'); + ctx.set('key', { foo: 'bar' }); +}); +``` + +See the official LoopBack +[documentation](https://docs.strongloop.com/display/APIC/Using+current+context) +for more details. diff --git a/browser/current-context.js b/browser/current-context.js new file mode 100644 index 0000000..955fbf8 --- /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 LoopBackContext = module.exports; + +LoopBackContext.getCurrentContext = function() { + return null; +}; + +LoopBackContext.runInContext = +LoopBackContext.createContext = function() { + throw new Error('Current context is not supported in the browser.'); +}; diff --git a/package.json b/package.json index a7b80fb..12585db 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "loopback-context", "version": "1.0.0", - "description": "Current context for LoopBack applications, based on node-continuation-local-storage", + "description": "Current context for LoopBack applications, based on cls-hooked", "keywords": [ "StrongLoop", "LoopBack", @@ -11,19 +11,25 @@ "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", + "test": "mocha -r cls-hooked", "posttest": "npm run lint", "lint": "eslint ." }, "license": "MIT", - "dependencies": {}, + "dependencies": { + "cls-hooked": "^4.0.1" + }, "devDependencies": { + "async": "1.5.2", + "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..a63ee67 --- /dev/null +++ b/server/current-context.js @@ -0,0 +1,92 @@ +// 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('cls-hooked'); +var domain = require('domain'); + +var LoopBackContext = 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. + */ +LoopBackContext.getCurrentContext = function() { + // A placeholder method, see LoopBackContext.createContext() for the real version + return null; +}; + +/** + * Run the given function in such way that + * `LoopBackContext.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. + */ +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 || LoopBackContext.createContext('loopback'); + + currentDomain.run(function() { + ns.run(function executeInContext(context) { + fn(ns, currentDomain); + }); + }); +}; + +/** + * Create a new LoopBackContext instance that can be used + * for `LoopBackContext.runInContext`. + * + * **NOTES** + * + * 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. + * + * 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. + */ +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 LoopBackContext.getCurrentContext() + LoopBackContext.getCurrentContext = function() { + return ns && ns.active ? ns : null; + }; + } + return ns; +}; + +/** + * Create middleware that sets up a new context for each incoming HTTP request. + * + * See perRequestContextFactory for more details. + */ +LoopBackContext.perRequest = require('./middleware/per-request'); diff --git a/server/middleware/per-request.js b/server/middleware/per-request.js new file mode 100644 index 0000000..54361e1 --- /dev/null +++ b/server/middleware/per-request.js @@ -0,0 +1,60 @@ +// 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 LoopBackContext = require('../current-context'); + +module.exports = perRequestContextFactory; + +var name = 'loopback'; + +/** + * Context middleware. + * ```js + * var perRequestContext = require( + * 'loopback-context/server/middleware/per-request-context.js'); + * var app = loopback(); + * 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. + */ + +function perRequestContextFactory(options) { + options = options || {}; + var scope = options.name || name; + var enableHttpContext = options.enableHttpContext || false; + var ns = LoopBackContext.createContext(scope); + + // Return the middleware + return function perRequestContext(req, res, next) { + if (req.loopbackContext) { + return next(); + } + + LoopBackContext.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..d1fa058 --- /dev/null +++ b/test/main.test.js @@ -0,0 +1,138 @@ +// 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'; + +// ASYNC VERSION MATTERS! 1.5.2 is required in order for this test to work. +var async = require('async'); +var LoopBackContext = require('..'); +var Domain = require('domain'); +var EventEmitter = require('events').EventEmitter; +var expect = require('./helpers/expect'); +var loopback = require('loopback'); +var request = require('supertest'); + +describe('LoopBack 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.set('legacyExplorer', false); + app.use(LoopBackContext.perRequest()); + 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 = LoopBackContext.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 = LoopBackContext.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) { + LoopBackContext.runInContext(function() { + var ctx = LoopBackContext.getCurrentContext(); + expect(ctx).is.an('object'); + ctx.set('test-key', 'test-value'); + process.nextTick(function() { + var ctx = LoopBackContext.getCurrentContext(); + expect(ctx).is.an('object'); + expect(ctx.get('test-key')).to.equal('test-value'); + + done(); + }); + }); + }); + + // Credits for the original idea for this test case to @marlonkjoseph + // Original source of the POC gist of the idea: + // https://gist.github.com/marlonkjoseph/f42f3c71f746896a0d4b7279a34ea753 + // Heavily edited by others + it('keeps context when using waterfall() from async 1.5.2', + function(done) { + LoopBackContext.runInContext(function() { + // function 1 which pulls context + var fn1 = function(cb) { + var ctx = LoopBackContext.getCurrentContext(); + expect(ctx).is.an('object'); + ctx.set('test-key', 'test-value'); + cb(); + }; + // function 2 which pulls context + var fn2 = function(cb) { + var ctx = LoopBackContext.getCurrentContext(); + expect(ctx).is.an('object'); + var testValue = ctx && ctx.get('test-key', 'test-value'); + cb(null, testValue); + }; + // Trigger async waterfall callbacks + var asyncFn = function() { + async.waterfall([ + fn1, + fn2, + ], function(err, testValue) { + expect(testValue).to.equal('test-value'); + done(); + }); + }; + asyncFn(); + }); + }); +});