├── .gitignore ├── .travis.yml ├── package.json ├── LICENSE ├── README.md ├── test └── index.js └── lib └── index.js /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | node_modules 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "0.10" 4 | - "0.11" 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cookieless", 3 | "version": "1.3.0", 4 | "description": "A cookieless identifier for user tracking by exploting etags.", 5 | "main": "lib/index.js", 6 | "scripts": { 7 | "start": "node lib/index.js", 8 | "test": "mocha test/" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/Colex/node-cookieless" 13 | }, 14 | "author": "Alex Correia", 15 | "keywords": [ 16 | "cookieless", 17 | "cookie", 18 | "beacon", 19 | "analytics", 20 | "statistics", 21 | "tracking", 22 | "tracker", 23 | "track", 24 | "etag" 25 | ], 26 | "licenses": [ 27 | { 28 | "type": "MIT", 29 | "url": "http://opensource.org/licenses/MIT" 30 | } 31 | ], 32 | "devDependencies": { 33 | "mocha": "~1.17.1", 34 | "should": "~3.1.2" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/Colex/node-cookieless.svg?branch=master)](https://travis-ci.org/Colex/node-cookieless) 2 | # Cookieless.js 3 | #### Cookieless user tracking for node.js 4 | 5 | Cookieless.js is a lightweight implementation of visitor's tracking using **ETag** for Node.js. It **may** be used on its own or along side other tracking methods (cookies or browser fingerprinting), so whenever one solution fails, you may fallback to a back up one. 6 | 7 | Read more about ETag [here](http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.19). 8 | 9 | This package allows you to use the visitor's information both **server side** and **client side** by providing a JSONP API. 10 | 11 | ## Install 12 | ```bash 13 | npm install cookieless --save 14 | ``` 15 | 16 | ## Tests 17 | ```bash 18 | npm test 19 | ``` 20 | 21 | ## Example 22 | #### Server side 23 | The following example starts a tracking beacon at: http://127.0.0.1/i.js?callback=setVisitor 24 | ```javascript 25 | var CookielessTracker = require('cookieless'); 26 | 27 | /* 28 | Note: it's not mandatory to start a beacon, you may handle the requests 29 | yourself and just use the tracker's API 30 | */ 31 | CookielessTracker.startBeacon(7123, '0.0.0.0', function(visitor) { 32 | redis.incr('visits.'+visitor.id); 33 | }); 34 | ``` 35 | #### Client side *(browser)* 36 | ```javascript 37 | $.ajax({ 38 | url: "http://127.0.0.1:7123/i.js", 39 | jsonp: "callback", 40 | dataType: "jsonp", 41 | success: function( visitor ) { 42 | //Do something 43 | trackImpressionFor(visitor.id, visitor.session); //example 44 | } 45 | }); 46 | ``` 47 | 48 | ## API 49 | #### *(static)* startBeacon(port=*7123*, host=*'0.0.0.0'*, onVisitorCallback) 50 | The easiest way of hit the ground running is by using the built-in lightweight beacon, which starts a listener and processes the tracking requests. If a **onVisitorCallback** function is given, it will be called everytime a visitor calls the endpoint. 51 | ```javascript 52 | var CookielessTracker = require('cookieless'); 53 | CookielessTracker.startBeacon(7123, '0.0.0.0', function(visitor) { 54 | console.log("Visitor " + visitor.id + " has visited us " + visitor.session + 55 | " times. Last time was on " + visitor.lastSeen); 56 | }); 57 | ``` 58 | The endpoint will be available at http://127.0.0.1/i.js?callback=setVisitor and the response will be: 59 | ```javascript 60 | ; typeof setVisitor === 'function' && setVisitor({id: 31428830410917,session: 3,lastSeen: 1428830410917}); 61 | ``` 62 | #### Contructor(request, update=true) 63 | Initializes a new visitor (may be returning visitor) from a request. If it is a **new visitor** it will automatically generate a **new unique ETag**. 64 | 65 | If **_update_** is set to **_true_**, it will automatcally update the visitor's session if they were last seen **over** 30 minutes ago. _(Otherwise you'll have to manually call the **update** API)_. 66 | ```javascript 67 | var CookielessTracker = require('cookieless'); 68 | 69 | http.createServer(function (req, res) { 70 | var visitor = new CookielessTracker(req); 71 | console.log("This visitor is on his " + visitor.session + " visit!"); 72 | visitor.respond(res); 73 | }); 74 | ``` 75 | #### respond([callback,] res) 76 | Given a *response* object, it will build and send a JSONP response to the visitor with the callback function name (if given). It is a combination of **statusCode()**, **buildHeader()**, and **buildScript()**. 77 | _(**Important:** the callback function name should be set and never changed, otherwise the tracking will be reset)_ 78 | ```javascript 79 | http.createServer(function (req, res) { 80 | var visitor = new CookielessTracker(req); 81 | // Do something 82 | visitor.respond('setVisitor', res); 83 | }); 84 | ``` 85 | #### buildScript([callback]) 86 | Builds the JSONP script with the visitor's information, the **callback** argument is a **string** with name of the callback function for the JSONP response. _(If the request has a callback set in the query string, it will be the default value)_ 87 | ```javascript 88 | console.log(visitor.buildScript('setVisitor')); 89 | //Outputs: 90 | //; typeof setVisitor === 'function' && setVisitor({id: 31428830410917,session: 3,lastSeen: 1428830410917}); 91 | ``` 92 | #### buildHeader() 93 | Builds the header for the response including the identifier ETag. 94 | ```javascript 95 | //Output: 96 | { 97 | 'Content-Type': 'text/javascript', 98 | 'ETag': "31428830410917.1428830410917.3" 99 | } 100 | ``` 101 | #### statusCode() 102 | It gives the most appropriate **status code** for a **tracking response**. The response only changes when the session number gets updated, in that case the stattus **will be 200**, any other case should return **304**. 103 | ```javascript 104 | var visitor = new CookielessTracker(req); 105 | res.writeHead(visitor.statusCode(), visitor.buildHeader()); 106 | ``` 107 | 108 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | var should = require('should'); 2 | var Cookieless = require('../lib/index'); 3 | 4 | var now = (new Date()).getTime(); 5 | 6 | var request = { 7 | simple: { 8 | url: 'http://tracker.com/i.js', 9 | headers: {} 10 | }, 11 | 12 | with_callback: { 13 | url: 'http://tracker.com/i.js?callback=setVisitor', 14 | headers: {} 15 | }, 16 | 17 | with_headers: { 18 | url: 'http://tracker.com/i.js', 19 | headers: { 20 | 'if-none-match': "681429050444587." + now + ".1" 21 | } 22 | }, 23 | 24 | with_old_headers: { //more than 30 minutes ago 25 | url: 'http://tracker.com/i.js', 26 | headers: { 27 | 'if-none-match': "751429049947678.1429043947680.2" 28 | } 29 | } 30 | } 31 | 32 | 33 | describe('Cookieless', function() { 34 | 35 | describe('on initialize', function() { 36 | it('should set the callback', function() { 37 | (new Cookieless(request.with_callback)).callback.should.equal('setVisitor'); 38 | }); 39 | 40 | it ('should set callback to "cookielessCallback" as default', function() { 41 | (new Cookieless(request.simple)).callback.should.equal('cookielessCallback'); 42 | }); 43 | 44 | it('should generate a ETag', function() { 45 | (new Cookieless(request.simple)).etag.should.match(/^[^.]+\.[^.]+\.1$/); 46 | }); 47 | 48 | it('should parse ETag correctly', function() { 49 | var tracker = new Cookieless(request.with_headers); 50 | tracker.id.should.equal('681429050444587'); 51 | tracker.lastSeen.should.equal(now); 52 | tracker.session.should.equal(1); 53 | }); 54 | 55 | it('should update the session correctly', function() { 56 | var tracker = new Cookieless(request.with_old_headers); 57 | var tracker_no_update = new Cookieless(request.with_old_headers, false); 58 | tracker.session.should.equal(3); 59 | tracker_no_update.session.should.equal(2); 60 | }); 61 | }); //on initialize 62 | 63 | describe('tracker object', function() { 64 | describe('on statusCode()', function() { 65 | it('should return 200 if new ETag', function() { 66 | (new Cookieless(request.simple)).statusCode().should.equal(200); 67 | }); 68 | 69 | it('should return 200 if ETag has not changed', function() { 70 | (new Cookieless(request.with_headers)).statusCode().should.equal(304); 71 | }); 72 | 73 | it('should return 304 if session is updated', function() { 74 | (new Cookieless(request.with_old_headers)).statusCode().should.equal(200); 75 | }); 76 | }); 77 | 78 | describe('on buildHeader()', function() { 79 | it('should send etag and text/javascript', function() { 80 | var header = (new Cookieless(request.with_old_headers, false)).buildHeader(); 81 | header.should.have.properties({ 82 | 'Content-Type': 'text/javascript', 83 | 'ETag': '751429049947678.1429043947680.2' 84 | }); 85 | }); 86 | }); 87 | 88 | describe('on buildScript()', function() { 89 | it('should return JSONP function call', function() { 90 | var jsonp = (new Cookieless(request.with_old_headers, false)).buildScript(); 91 | jsonp.should.equal("; typeof cookielessCallback === 'function' && cookielessCallback({id: 751429049947678,session: 2,lastSeen: 1429043947680});"); 92 | }); 93 | 94 | it('should use callback if passed in argument', function() { 95 | var jsonp = (new Cookieless(request.with_old_headers, false)).buildScript('setVisitor'); 96 | jsonp.should.equal("; typeof setVisitor === 'function' && setVisitor({id: 751429049947678,session: 2,lastSeen: 1429043947680});"); 97 | }); 98 | }); 99 | 100 | describe('on respond()', function() { 101 | it('should send script with headers', function() { 102 | var tracker = new Cookieless(request.with_old_headers); 103 | tracker.respond({ writeHead: writeHead, end: end }); 104 | 105 | function writeHead(status, header) { 106 | status.should.equal(200); 107 | header['Content-Type'].should.equal('text/javascript'); 108 | header['ETag'].should.match(/751429049947678\.[0-9]+\.3/); 109 | } 110 | 111 | function end(script) { 112 | script.should.match(/; typeof cookielessCallback === \'function\' .+ cookielessCallback\([^)]+\)\;/); 113 | } 114 | }); 115 | 116 | it('should send script with given callback', function() { 117 | var tracker = new Cookieless(request.with_old_headers); 118 | tracker.respond('setVisitor', { writeHead: function(){}, end: end }); 119 | 120 | function end(script) { 121 | script.should.match(/; typeof setVisitor === \'function\' .+ setVisitor\([^)]+\)\;/); 122 | } 123 | }); 124 | }); 125 | }); //tracker object 126 | 127 | describe('on generateId()', function() { 128 | it('should generate ID', function() { 129 | var id = Cookieless.generateId(); 130 | (typeof id).should.equal('string'); 131 | id.length.should.not.equal(0); 132 | }); 133 | 134 | it('should generate a unique ID', function() { 135 | Cookieless.generateId().should.not.equal(Cookieless.generateId()); 136 | }); 137 | }); //on generateId() 138 | 139 | }); //cookieless 140 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | var http = require('http'); 2 | var url = require('url'); 3 | 4 | /** 5 | * Tracker 6 | * Cookieless tracker class. Generates IDs for new visitors 7 | * and tracks visitors' sessions 8 | * @param Object req A reference for the request 9 | * @param Boolean update Optional (default: true) Pass false if you do not want 10 | * the session to be updated automatically 11 | */ 12 | function Tracker(req, update) { 13 | if (typeof update === 'undefined') update = true; 14 | var reqUrl = url.parse(req.url, true); 15 | this.updated = false; 16 | this.callback = reqUrl.query.callback || 'cookielessCallback'; 17 | this.etag = req.headers['if-none-match'] || this.generateEtag(); 18 | this.parse(); 19 | if (update) 20 | this.update(); 21 | } 22 | 23 | /** 24 | * Generate ETag 25 | * Generates the visitor's unique ETag based on their information 26 | * {id}.{date now}.{session number} 27 | * @return {String} The generated ETag 28 | */ 29 | Tracker.prototype.generateEtag = function() { 30 | var now = (new Date()).getTime(); 31 | this.id = this.id || Tracker.generateId(); 32 | this.session = this.session || 1; 33 | this.updated = true; 34 | return this.id+"."+now+"."+this.session; 35 | } 36 | 37 | /** 38 | * Generate ID (static) 39 | * Generates a unique ID for a visitor 40 | * @return {String} The visitor's unique identifier 41 | */ 42 | Tracker.generateId = function() { 43 | var now = (new Date()).getTime(); 44 | return Math.floor(Math.random()*100)+""+now; 45 | } 46 | 47 | /** 48 | * Update 49 | * Updates the visitor's session if they were last seen 50 | * more than 30 minutes ago. 51 | * @return {Object} Tracker object 52 | */ 53 | Tracker.prototype.update = function() { 54 | if (!this.updated) { 55 | var now = (new Date()).getTime(); 56 | var diff = now - this.lastSeen; 57 | if (diff >= 1800000) { 58 | this.session++; 59 | this.etag = this.generateEtag(); 60 | } 61 | } 62 | return this; 63 | } 64 | 65 | /** 66 | * Parse 67 | * Parses the ETag to extract the visitor's information 68 | * @return {Object} The tracker object 69 | */ 70 | Tracker.prototype.parse = function() { 71 | this.id = this.etag.match(/^([^.]+)/)[1]; 72 | this.lastSeen = parseInt(this.etag.match(/^[^.]+\.([^.]+)/)[1]); 73 | this.session = parseInt(this.etag.match(/\.([^.]+)$/)[1]); 74 | return this; 75 | } 76 | 77 | /** 78 | * Status Code 79 | * Returns the status code that should be sent with the response 80 | * @return {Number} Returns 200 if the response has changed, 304 otherwise 81 | */ 82 | Tracker.prototype.statusCode = function() { 83 | return this.updated ? 200 : 304; 84 | } 85 | 86 | /** 87 | * Build Header 88 | * Builds a response header that may be extended and sent 89 | * with the response. It will see the Content-Type to text/javascript 90 | * and the ETag depending on the unique generated value. 91 | * @return {Object} The header object with Content-Type and ETag set 92 | */ 93 | Tracker.prototype.buildHeader = function() { 94 | return { 95 | 'Content-Type': 'text/javascript', 96 | 'ETag': this.etag 97 | } 98 | } 99 | 100 | /** 101 | * Build Script 102 | * Builds the JSONP callback script with the visitor's information 103 | * as the argument for the callback function. 104 | * @param {String} callback Name of the callback function 105 | * @return {String} Snippet of javascript for the JSONP response 106 | */ 107 | Tracker.prototype.buildScript = function(callback) { 108 | callback = callback || this.callback; 109 | return "; typeof "+callback+" === 'function' && "+ 110 | callback+"({"+ 111 | "id: "+this.id+","+ 112 | "session: "+this.session+","+ 113 | "lastSeen: "+this.lastSeen+"}"+ 114 | ");"; 115 | } 116 | 117 | /** 118 | * Respond 119 | * Builds the response headers and body. 120 | * The response will be 304 if the etag has not been updated, otherwise it will be 200. 121 | * The body is a JSONP script with the information about the user. 122 | * @param {String} callback (optional) Name of the JSONP callback function 123 | * @param {Object} res The response object 124 | * @return {Object} The same response object after res.end() 125 | */ 126 | Tracker.prototype.respond = function(callback, res) { 127 | if (typeof res === 'undefined') { 128 | res = callback; 129 | callback = null; 130 | } 131 | callback = callback || this.callback; 132 | res.writeHead(this.statusCode(), this.buildHeader()); 133 | if (this.updated) 134 | res.end(this.buildScript(callback)); 135 | else 136 | res.end(); 137 | return res; 138 | } 139 | 140 | /** 141 | * Start Beacon (static) 142 | * Starts a tracking server in a given port and interface. 143 | * It will listen for requests on http://0.0.0.0/i.js[?callback=setVisitor]. 144 | * @param {Number} port Port where the server will be listening 145 | * @param {String} host The interface the server will start 146 | * @param {Function} onVisitorCallback A callback that will be called everytime a 147 | * visitor calls the beacon 148 | */ 149 | Tracker.startBeacon = function(port, host, onVisitorCallback) { 150 | var proxy = http.createServer(function (req, res) { 151 | if (req.url.match(/^\/i\.js/)) { 152 | var tracker = new Tracker(req, true); 153 | if (typeof onVisitorCallback === 'function') onVisitorCallback(tracker); 154 | tracker.respond(res); 155 | } else { 156 | res.end(); 157 | } 158 | }); 159 | 160 | return proxy.listen(port || 7123, host || '127.0.0.1'); 161 | } 162 | 163 | module.exports = Tracker; 164 | --------------------------------------------------------------------------------