diff --git a/.npmignore b/.npmignore index 099f148..8aeb78f 100644 --- a/.npmignore +++ b/.npmignore @@ -62,6 +62,7 @@ yarn.lock #IDE .idea .history +.vscode test bin diff --git a/README.md b/README.md index ba67097..aa08c73 100644 --- a/README.md +++ b/README.md @@ -156,7 +156,7 @@ result.os.toString(); Or access parts of these properties directly: -```php +```js result.browser.name; // Chrome @@ -172,7 +172,7 @@ result.engine.name; Finally you can also query versions directly: -```php +```js result.browser.version.is('>', 26); // true @@ -192,46 +192,56 @@ In some cases you may want to disable the detection of bots. This allows the bot ```js result = new WhichBrowser(request.headers, { detectBots: false }); ``` - +> Be aware that changing `cacheExpiries` or `cacheCheckInterval` impact the cache validity check rate for **ALL** records in the cache. For example setting `cacheExpiries` to `0` will prevent **ALL** results to expire because it will disable the cache validity check (for the sake of truth it will be done every `57085` years, `5` months, `10` days, `7` hours, `35` minutes and `48` seconds). API reference ------------- diff --git a/package.json b/package.json index c2e9afa..a3c75c8 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "which-browser", - "version": "0.1.0", - "description": "Browser sniffing tool and UA parser. This library is a porting of WhichBrowser/Parser", + "version": "0.2.0", + "description": "Browser sniffing tool and UA parser. Porting to VanillaJS of the PHP library WhichBrowser/Parser", "main": "src/Parser.js", "scripts": { "test": "lab -a code -v", diff --git a/src/Cache.js b/src/Cache.js new file mode 100644 index 0000000..817e922 --- /dev/null +++ b/src/Cache.js @@ -0,0 +1,100 @@ +const Analyser = require('./Analyser'); +const Crypto = require('crypto'); +const simpleCache = new Map(); +let cacheCleanerInterval; +let actualCacheCheckInterval; + +/** + * Class that enable caching of results + */ +class Cache { + /** + * Analyse the provided headers or User-Agent string + * + * @param {object|string} headers An object with all of the headers or a string with just the User-Agent header + * @param {object} options An object with configuration options + * @param {object} that An object that is the Parser this + * + * @return {boolean|object} Returns true if the cache is active but no match found, return the match if found + */ + static analyseWithCache(headers, options, that) { + if (options.cache) { + const Parser = require('./Parser'); + options.cacheExpires = options.cacheExpires <= 0 ? Number.MAX_SAFE_INTEGER : options.cacheExpires || 900; + options.cacheCheckInterval = Math.max(options.cacheCheckInterval || parseInt(options.cacheExpires / 5, 10), 1); + switch (options.cache) { + case Parser.SIMPLE_CACHE: { + /* Should set the clearCache interval only if cacheExpires is > 0 and it hasn't yet initializated + or the cacheCheckInterval has changed */ + if ( + (!cacheCleanerInterval || actualCacheCheckInterval !== options.cacheCheckInterval) && + options.cacheExpires + ) { + clearInterval(cacheCleanerInterval); + cacheCleanerInterval = setInterval(Cache.clearCache, options.cacheCheckInterval * 1000); + } + actualCacheCheckInterval = options.cacheCheckInterval; + const cacheKey = Crypto.createHash('sha256').update(JSON.stringify(headers)).digest('hex'); + if (simpleCache.has(cacheKey)) { + const hit = simpleCache.get(cacheKey); + hit.expires = Cache.getExpirationTime(options); + return hit.data; + } else { + const analyser = new Analyser(headers, options); + analyser.setData(that); + analyser.analyse(); + simpleCache.set(cacheKey, { + data: { + browser: that.browser, + engine: that.engine, + os: that.os, + device: that.device, + camouflage: that.camouflage, + features: that.features, + }, + expires: Cache.getExpirationTime(options), + }); + } + } + } + return true; + } + return false; + } + + /** + * Check if data in cache is expired + */ + static clearCache() { + simpleCache.forEach((v, k) => { + if (v.expires < +new Date()) { + simpleCache.delete(k); + } + }); + } + + /** + * Compute the expiration time + * + * @param {object} options An object with configuration options + * + * @return {int} The expiration time timestamp + */ + static getExpirationTime(options) { + return options.cacheExpires === Number.MAX_SAFE_INTEGER + ? Number.MAX_SAFE_INTEGER + : +new Date() + options.cacheExpires * 1000; + } + + /** + * Reset the class state. Used only for test purpose + */ + static resetClassState() { + simpleCache.clear(); + clearInterval(cacheCleanerInterval); + cacheCleanerInterval = null; + actualCacheCheckInterval = null; + } +} + +module.exports = Cache; diff --git a/src/Parser.js b/src/Parser.js index cc85dbe..76458cc 100644 --- a/src/Parser.js +++ b/src/Parser.js @@ -1,5 +1,7 @@ const Main = require('./model/Main'); const Analyser = require('./Analyser'); +const Cache = require('./Cache'); + /** * Class that parse the user-agent */ @@ -8,7 +10,9 @@ class Parser extends Main { * Create a new object that contains all the detected information * * @param {object|string} headers Optional, an object with all of the headers or a string with just the User-Agent header - * @param {object} options Optional, an object with configuration options + * @param {object} options Optional, an object with configuration options + * @param {int} [options.cacheExpires=900] Expiry time in seconds + * @param {int} [options.cacheCheckInterval=1/5 * options.cacheExpires] Time in seconds between each cache check to remove expired records. Minimum 1 */ constructor(headers = null, options = {}) { super(); @@ -37,15 +41,19 @@ class Parser extends Main { } else { h = headers; } - - /* if (this.analyseWithCache(h, o)) { - return; - }*/ + let data; + if ((data = Cache.analyseWithCache(h, o, this))) { + if (typeof data === 'object') { + Object.assign(this, data); + this.cached = true; + } + return; + } const analyser = new Analyser(h, o); analyser.setData(this); analyser.analyse(); } } - +Parser.SIMPLE_CACHE = 'simple'; module.exports = Parser; diff --git a/test/unit/CacheTest.js b/test/unit/CacheTest.js new file mode 100644 index 0000000..fd45ed8 --- /dev/null +++ b/test/unit/CacheTest.js @@ -0,0 +1,430 @@ +const {describe, it, beforeEach, afterEach} = (exports.lab = require('lab').script()); +const expect = require('code').expect; +const Sinon = require('sinon'); +const Parser = require('../../src/Parser'); +const Cache = require('../../src/Cache'); +const header1 = 'Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.0; InfoPath.1)'; +const header2 = + 'User-Agent: Mozilla/5.0 (Linux; Android 4.0.3; GT-P3113 Build/IML74K) AppleWebKit/535.19 (KHTML, like Gecko) Chrome/18.0.1025.133 Safari/535.19'; +const comparision1 = { + browser: {name: 'Internet Explorer', version: '6.0', type: 'browser'}, + engine: {name: 'Trident'}, + os: {name: 'Windows', version: {value: '5.0', alias: '2000'}}, + device: {type: 'desktop'}, +}; +const comparision2 = { + browser: {name: 'Chrome', version: '18', type: 'browser'}, + engine: {name: 'Webkit', version: '535.19'}, + os: {name: 'Android', version: '4.0.3'}, + device: {type: 'tablet', manufacturer: 'Samsung', model: 'Galaxy Tab 2 7.0'}, +}; +let clock; + +describe('Cache Class', () => { + beforeEach((done) => { + Cache.resetClassState(); + clock = Sinon.useFakeTimers('setInterval', 'clearInterval', 'Date'); + done(); + }); + afterEach((done) => { + clock.restore(); + done(); + }); + describe('Two subsequent parse with same UA', () => { + it('should be cached', (done) => { + let result = new Parser(header1, {cache: Parser.SIMPLE_CACHE}); + + expect(result.toObject()).to.be.equal(comparision1); + expect(result.cached).to.not.exist(); + result = new Parser(header1, {cache: Parser.SIMPLE_CACHE}); + expect(result.toObject()).to.be.equal(comparision1); + expect(result.cached).to.be.true(); + + done(); + }); + }); + + describe('Two subsequent parse with different UA', () => { + it('should not be cached', (done) => { + let result = new Parser(header1, {cache: Parser.SIMPLE_CACHE}); + + expect(result.toObject()).to.be.equal(comparision1); + expect(result.cached).to.not.exist(); + result = new Parser(header2, {cache: Parser.SIMPLE_CACHE}); + expect(result.toObject()).to.be.equal(comparision2); + expect(result.cached).to.not.exist(); + done(); + }); + }); + + describe('Create cache with negative expiry time', () => { + it('should let the Cache never expire', (done) => { + let result = new Parser(header1, {cache: Parser.SIMPLE_CACHE, cacheExpires: -1}); + expect(result.toObject()).to.be.equal(comparision1); + expect(result.cached).to.not.exist(); + + // let pass 2 years + clock.tick(60 * 60 * 24 * 265 * 2 * 1000); + + // it's still in cache + result = new Parser(header1, {cache: Parser.SIMPLE_CACHE}); + expect(result.toObject()).to.be.equal(comparision1); + expect(result.cached).to.be.true(); + + done(); + }); + }); + + describe('Create cache with an expiry time that would make the cache check interval be 0s (cacheExpires/5), ', () => { + it('should instead be 1s', (done) => { + let result = new Parser(header1, {cache: Parser.SIMPLE_CACHE, cacheExpires: 4}); + expect(result.toObject()).to.be.equal(comparision1); + expect(result.cached).to.not.exist(); + + // let pass 3 seconds + clock.tick(3 * 1000); + + result = new Parser(header1, {cache: Parser.SIMPLE_CACHE, cacheExpires: 4}); + expect(result.toObject()).to.be.equal(comparision1); + expect(result.cached).to.be.true(); + + // let pass 4 seconds + cache check inteval + clock.tick((4 + 1 + 1) * 1000); + + // No more in cache + result = new Parser(header1, {cache: Parser.SIMPLE_CACHE, cacheExpires: 4}); + expect(result.toObject()).to.be.equal(comparision1); + expect(result.cached).to.not.exist(); + + done(); + }); + }); + + describe('Default cache with default expiry time and expiry check timer', () => { + it('should be empty after expiry time 900s + 1/5 * 900s + 1s', (done) => { + let result = new Parser(header1, {cache: Parser.SIMPLE_CACHE}); + + expect(result.toObject()).to.be.equal(comparision1); + expect(result.cached).to.not.exist(); + + result = new Parser(header1, {cache: Parser.SIMPLE_CACHE}); + expect(result.toObject()).to.be.equal(comparision1); + expect(result.cached).to.be.true(); + + clock.tick((900 + 900 / 5 + 1) * 1000); + + result = new Parser(header1, {cache: Parser.SIMPLE_CACHE}); + expect(result.toObject()).to.be.equal(comparision1); + expect(result.cached).to.not.exist(); + + done(); + }); + }); + + describe('Default cache with default expiry time but with 1s expiry check timer', () => { + it('should be empty after expiry time 900s + 1s + 1s', (done) => { + let result = new Parser(header1, {cache: Parser.SIMPLE_CACHE, cacheCheckInterval: 1}); + + expect(result.toObject()).to.be.equal(comparision1); + expect(result.cached).to.not.exist(); + + result = new Parser(header1, {cache: Parser.SIMPLE_CACHE, cacheCheckInterval: 1}); + expect(result.toObject()).to.be.equal(comparision1); + expect(result.cached).to.be.true(); + + clock.tick((900 + 1 + 1) * 1000); + + result = new Parser(header1, {cache: Parser.SIMPLE_CACHE, cacheCheckInterval: 1}); + expect(result.toObject()).to.be.equal(comparision1); + expect(result.cached).to.not.exist(); + + done(); + }); + }); + + describe('Default cache with 100s expiry time and default expiry check timer', () => { + it('should be empty after expiry time 100s + 1/5 * 100s + 1s', (done) => { + let result = new Parser(header1, {cache: Parser.SIMPLE_CACHE, cacheExpires: 100}); + + expect(result.toObject()).to.be.equal(comparision1); + expect(result.cached).to.not.exist(); + + result = new Parser(header1, {cache: Parser.SIMPLE_CACHE, cacheExpires: 100}); + expect(result.toObject()).to.be.equal(comparision1); + expect(result.cached).to.be.true(); + + clock.tick((100 + 100 / 5 + 1) * 1000); + + result = new Parser(header1, {cache: Parser.SIMPLE_CACHE, cacheExpires: 100}); + expect(result.toObject()).to.be.equal(comparision1); + expect(result.cached).to.not.exist(); + + done(); + }); + }); + + describe('Default cache with 100s expiry time but with 2s expiry check timer', () => { + it('should be empty after expiry time 100s + 2s + 1s', (done) => { + let result = new Parser(header1, {cache: Parser.SIMPLE_CACHE, cacheExpires: 100, cacheCheckInterval: 2}); + + expect(result.toObject()).to.be.equal(comparision1); + expect(result.cached).to.not.exist(); + + result = new Parser(header1, {cache: Parser.SIMPLE_CACHE, cacheExpires: 100, cacheCheckInterval: 2}); + expect(result.toObject()).to.be.equal(comparision1); + expect(result.cached).to.be.true(); + + clock.tick((100 + 2 + 1) * 1000); + + result = new Parser(header1, {cache: Parser.SIMPLE_CACHE, cacheExpires: 100, cacheCheckInterval: 2}); + expect(result.toObject()).to.be.equal(comparision1); + expect(result.cached).to.not.exist(); + + done(); + }); + }); + + describe('Default cache with default expiry time but with 1s expiry check timer, one item with cache refreshed and one not', () => { + it('the one refreshed should be in cache after the first expiry time (900 + 1 + 1), the other not', (done) => { + // First call to Parser, cache both + let result = new Parser(header1, {cache: Parser.SIMPLE_CACHE, cacheCheckInterval: 1}); + let result2 = new Parser(header2, {cache: Parser.SIMPLE_CACHE, cacheCheckInterval: 1}); + + expect(result.toObject()).to.be.equal(comparision1); + expect(result.cached).to.not.exist(); + + expect(result2.toObject()).to.be.equal(comparision2); + expect(result2.cached).to.not.exist(); + + // Second call, check that are cached + result = new Parser(header1, {cache: Parser.SIMPLE_CACHE, cacheCheckInterval: 1}); + result2 = new Parser(header2, {cache: Parser.SIMPLE_CACHE, cacheCheckInterval: 1}); + + expect(result.toObject()).to.be.equal(comparision1); + expect(result.cached).to.be.true(); + + expect(result2.toObject()).to.be.equal(comparision2); + expect(result2.cached).to.be.true(); + + // Make the timer be 1 second before the cache expiry time + clock.tick(899 * 1000); + + // Refresh only the record related to header 1 + result = new Parser(header1, {cache: Parser.SIMPLE_CACHE, cacheCheckInterval: 1}); + expect(result.toObject()).to.be.equal(comparision1); + expect(result.cached).to.be.true(); + + // Make the cache expiry and wait that the check interval has passed + clock.tick(3 * 1000); + + // Call the parser with header1, it should be still in cache + result = new Parser(header1, {cache: Parser.SIMPLE_CACHE, cacheCheckInterval: 1}); + expect(result.toObject()).to.be.equal(comparision1); + expect(result.cached).to.be.true(); + + // Call the parser with header2, it should NOT be in cache + result2 = new Parser(header2, {cache: Parser.SIMPLE_CACHE, cacheCheckInterval: 1}); + expect(result2.toObject()).to.be.equal(comparision2); + expect(result2.cached).to.not.exist(); + done(); + }); + }); + + describe('Change the expiry time while cache is populated', () => { + it('should affect only new items, and updated the expiry time after a new cache hit', (done) => { + // First call to Parser, still not cached, expiry time default to 900s + let result = new Parser(header1, {cache: Parser.SIMPLE_CACHE}); + expect(result.toObject()).to.be.equal(comparision1); + + expect(result.cached).to.not.exist(); + + // let pass 200s + clock.tick(200 * 1000); + + // Put in cache a new item with 100s expiry time + let result2 = new Parser(header2, {cache: Parser.SIMPLE_CACHE, cacheExpires: 100}); + + expect(result2.toObject()).to.be.equal(comparision2); + expect(result2.cached).to.not.exist(); + + // let expire the second item + clock.tick((100 + 100 / 5 + 1) * 1000); + + result2 = new Parser(header2, {cache: Parser.SIMPLE_CACHE, cacheExpires: 100}); + + expect(result2.toObject()).to.be.equal(comparision2); + expect(result2.cached).to.not.exist(); + + // First item is still in cache, parse it and update the expiry time + result = new Parser(header1, {cache: Parser.SIMPLE_CACHE, cacheExpires: 100}); + expect(result.toObject()).to.be.equal(comparision1); + expect(result.cached).to.be.true(); + + // Let expiry the first item. + clock.tick((100 + 100 / 5 + 1) * 1000); + + // Item has expired also if the first 900s are not passed + result = new Parser(header1, {cache: Parser.SIMPLE_CACHE, cacheExpires: 100}); + expect(result.toObject()).to.be.equal(comparision1); + expect(result.cached).to.not.exist(); + + done(); + }); + }); + + describe('Parse UA with no expiry time', () => { + it('should never expire', (done) => { + // First call to Parser, still not cached, expiry time default to 900s + let result = new Parser(header1, {cache: Parser.SIMPLE_CACHE, cacheExpires: 0}); + expect(result.toObject()).to.be.equal(comparision1); + expect(result.cached).to.not.exist(); + + // let pass 2 years + clock.tick(60 * 60 * 24 * 265 * 2 * 1000); + + // it's still in cache + result = new Parser(header1, {cache: Parser.SIMPLE_CACHE}); + expect(result.toObject()).to.be.equal(comparision1); + expect(result.cached).to.be.true(); + + done(); + }); + }); + + describe('Disable cache expiry when cache has already some item inside,', () => { + it('also old item with expiry time should never expire', (done) => { + // First call to Parser, still not cached, expiry time default to 900s + let result = new Parser(header1, {cache: Parser.SIMPLE_CACHE}); + expect(result.toObject()).to.be.equal(comparision1); + expect(result.cached).to.not.exist(); + + // let pass half expiration time + clock.tick(450 * 1000); + + let result2 = new Parser(header2, {cache: Parser.SIMPLE_CACHE, cacheExpires: 0}); + expect(result2.toObject()).to.be.equal(comparision2); + expect(result2.cached).to.not.exist(); + + // let pass 2 years + clock.tick(60 * 60 * 24 * 265 * 2 * 1000); + + // it's still in cache + result = new Parser(header1, {cache: Parser.SIMPLE_CACHE}); + expect(result.toObject()).to.be.equal(comparision1); + expect(result.cached).to.be.true(); + + result2 = new Parser(header2, {cache: Parser.SIMPLE_CACHE}); + expect(result2.toObject()).to.be.equal(comparision2); + expect(result2.cached).to.be.true(); + + done(); + }); + }); + + describe('Enable cache expiry when cache has already some item inside with no expiry time,', () => { + it('Old items are still there when the new expiry time come', (done) => { + // First call to Parser, still not cached, expiry time default to 900s + let result = new Parser(header1, {cache: Parser.SIMPLE_CACHE, cacheExpires: 0}); + expect(result.toObject()).to.be.equal(comparision1); + expect(result.cached).to.not.exist(); + + // let pass 2 years + clock.tick(60 * 60 * 24 * 265 * 2 * 1000); + + // it's still in cache + result = new Parser(header1, {cache: Parser.SIMPLE_CACHE, cacheExpires: 0}); + expect(result.toObject()).to.be.equal(comparision1); + expect(result.cached).to.be.true(); + + let result2 = new Parser(header2, {cache: Parser.SIMPLE_CACHE}); + expect(result2.toObject()).to.be.equal(comparision2); + expect(result2.cached).to.not.exist(); + + // expiry the second record + clock.tick((900 + 900 / 5 + 1) * 1000); + + result = new Parser(header1, {cache: Parser.SIMPLE_CACHE}); + expect(result.toObject()).to.be.equal(comparision1); + expect(result.cached).to.be.true(); + + result2 = new Parser(header2, {cache: Parser.SIMPLE_CACHE}); + expect(result2.toObject()).to.be.equal(comparision2); + expect(result2.cached).to.not.exists(); + + done(); + }); + }); + + describe('Default cache with 100s expiry time but with 2s expiry check timer', () => { + it('should be empty after expiry time 100s + 2s + 1s', (done) => { + let result = new Parser(header1, {cache: Parser.SIMPLE_CACHE, cacheExpires: 100, cacheCheckInterval: 2}); + + expect(result.toObject()).to.be.equal(comparision1); + expect(result.cached).to.not.exist(); + + result = new Parser(header1, {cache: Parser.SIMPLE_CACHE, cacheExpires: 100, cacheCheckInterval: 2}); + expect(result.toObject()).to.be.equal(comparision1); + expect(result.cached).to.be.true(); + + clock.tick((100 + 2 + 1) * 1000); + + result = new Parser(header1, {cache: Parser.SIMPLE_CACHE, cacheExpires: 100, cacheCheckInterval: 2}); + expect(result.toObject()).to.be.equal(comparision1); + expect(result.cached).to.not.exist(); + + done(); + }); + }); + + describe('Default cache with default expiry time and default check interval, after a second parse that change the check internval', () => { + it('should correctly update the cache check loop', (done) => { + // First call to Parser, cache both + let result = new Parser(header1, {cache: Parser.SIMPLE_CACHE}); + expect(result.toObject()).to.be.equal(comparision1); + expect(result.cached).to.not.exist(); + + // Make the timer be 1 second before the cache expiry time + clock.tick(899 * 1000); + + let result2 = new Parser(header2, {cache: Parser.SIMPLE_CACHE, cacheCheckInterval: 1}); + + // Let the cache expire for header 1 and let the cache check interval pass + clock.tick((1 + 1 + 1) * 1000); + + // header 1 record should be gone, header 2 one shoul be there in cache + result = new Parser(header1, {cache: Parser.SIMPLE_CACHE}); + result2 = new Parser(header2, {cache: Parser.SIMPLE_CACHE}); + expect(result.toObject()).to.be.equal(comparision1); + expect(result.cached).to.not.exist(); + + expect(result2.toObject()).to.be.equal(comparision2); + expect(result2.cached).to.be.true(); + + done(); + }); + }); + + describe('Default cache with default expiry time and default check interval: second parse is done without cache', () => { + it('the result returned should not come from cache', (done) => { + // First call to Parser, cache both + let result = new Parser(header1, {cache: Parser.SIMPLE_CACHE}); + expect(result.toObject()).to.be.equal(comparision1); + expect(result.cached).to.not.exist(); + + // let pass some time + clock.tick(100 * 1000); + + // cache is populated with header1 record + result = new Parser(header1, {cache: Parser.SIMPLE_CACHE}); + expect(result.toObject()).to.be.equal(comparision1); + expect(result.cached).to.be.true(); + + // parse the same header without cache property in options + result = new Parser(header1, {cacheCheckInterval: 1}); + expect(result.toObject()).to.be.equal(comparision1); + expect(result.cached).to.not.exist(); + + done(); + }); + }); +}); diff --git a/test/unit/CompleteTest.js b/test/unit/MassiveTest.js similarity index 97% rename from test/unit/CompleteTest.js rename to test/unit/MassiveTest.js index dd48a59..7556eaa 100644 --- a/test/unit/CompleteTest.js +++ b/test/unit/MassiveTest.js @@ -29,6 +29,7 @@ function makeTest(options) { expect(parserObj.toString()).to.be.equal(options.readable); expect(parserObj.toObject()).to.be.equal(options.result); + expect(parserObj.cached).to.not.exists(); done(); }); }