├── .gitignore ├── .jshintrc ├── .npmignore ├── .travis.yml ├── LICENSE ├── README.md ├── dist ├── gumshoe.js └── gumshoe.min.js ├── examples └── transports │ └── transport-example.js ├── gulpfile.js ├── lib ├── _polyfills.js ├── perfnow.js ├── query-string.js ├── reqwest.js └── store2.js ├── logo.png ├── package.json ├── src └── gumshoe.js └── test ├── fixtures.js ├── promise ├── rsvp-runner.html └── rsvp-specs.js ├── runner-dist.html ├── runner.html ├── sessions ├── sessions-runner.html └── sessions-specs.js └── specs.js /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "curly": true, 3 | "expr": true, 4 | "newcap": true, 5 | "quotmark": "single", 6 | "regexdash": true, 7 | "trailing": true, 8 | "undef": true, 9 | "unused": false, 10 | "maxerr": 100, 11 | "eqnull": true, 12 | "sub": false, 13 | "browser": true, 14 | "node": true, 15 | "esnext": true, 16 | "globals": { 17 | "createModule": false, 18 | "requireModules": false, 19 | "requireSpecs": false, 20 | "getJSONString": false, 21 | "describe": false, 22 | "xdescribe": false, 23 | "it": false, 24 | "xit": false, 25 | "expect": false, 26 | "jasmine": false, 27 | "spyOn": false, 28 | "afterEach": false, 29 | "beforeEach": false, 30 | "waits": false, 31 | "waitsFor": false, 32 | "runs": false, 33 | "sinon": false 34 | } 35 | } -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .gitignore 2 | .jshintrc 3 | .npmignore 4 | .travis.yml 5 | gulpfile.js 6 | test/ -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | node_js: 4 | - '7.6' 5 | 6 | before_install: 7 | - npm install -g gulp --loglevel=silent 8 | - echo `realpath node_modules` 9 | 10 | script: 11 | - gulp test 12 | 13 | matrix: 14 | fast_finish: true 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Gilt Groupe, Inc., Andrew Powell 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 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Gumshoe [![Build Status](https://travis-ci.org/gumshoe/Gumshoe.svg?branch=master)](https://travis-ci.org/gumshoe/Gumshoe) 2 | 3 | 4 | 5 | An analytics and event tracking sleuth. 6 | 7 | ## Background 8 | 9 | Companies of all sizes are heavily dependent upon analytics to improve the user experience and forecast varying business data points. Gilt has leveraged Google Analytics (henceforth known as GA) heavily, utilizing the data redirection feature of GA. At some point in late 2015, that feature will be no more. Gumshoe was built to fill that void and to extend upon the data-collection abilities of GA. 10 | 11 | ## Browser Support 12 | 13 | Gumshoe supports the latest versions of: 14 | 15 | `Chrome` 16 | 17 | `Firefox` 18 | 19 | `Safari` 20 | 21 | And versions 8 - Latest of `Internet Explorer`. Sorry, but Gumshoe will not be supporting other `OldIE` versions. 22 | 23 | 24 | ## Structure 25 | 26 | Gumshoe is comprised of several simple sturctural elements: 27 | 28 | `dist` contains the compiled gumshoe files meant for distribution and use in the browser. Comes in minified and unminified flavors. 29 | 30 | `examples` contains small examples of working with Gumshoe. 31 | 32 | `lib` contains third party libraries bundled with Gumshoe which facilitate standardized and privately scoped functionality for each gumshoe instance. 33 | 34 | `src` contains the source for the Gumshoe library itself. 35 | 36 | `test` contains [mocha BDD](http://mochajs.org/) tests 37 | 38 | ## Project Goals 39 | 40 | - Parity with Google Analytics base page data 41 | - Organized event names and data 42 | - High level of data integrity and confidence 43 | - Low page footprint 44 | - Low failure and miss rate 45 | 46 | ## Base Concepts 47 | 48 | **Transport** 49 | 50 | Transports describe the way in which data is sent from Gumshoe to an endpoint where it is ultimately stored and/or analyized. Each implementation of Gumshoe is responsible for initializing its own transport. Once data for an event has been collected and the event has been queued, Gumshoe will attempt to send the data using the defined transport. Gumshoe also supports multiple transports for sending data to multiple endpoints. 51 | 52 | **Event Name** 53 | 54 | An event name should be carefully considered, and all event names should be of the same format, tense, and general structure. At Gilt we use a dot-delimited event naming notation. eg. `'checkout.country.selected'`. 55 | 56 | **Event Data** 57 | 58 | Event data can be anything, of any type, that you or your organization decide upon. At Gilt, all event data is serialized to a string using JSON.stringify. Event data should be chosen carefully, should be documented and should not change in structure for a particular event, if time-over-time reporting is a priority. 59 | 60 | ## Testing 61 | 62 | ``` 63 | npm install 64 | gulp test 65 | ``` 66 | 67 | To independently test the distribution version of Gumshoe, run: 68 | 69 | ``` 70 | gulp test-dist 71 | ``` 72 | 73 | ## Contributing 74 | 75 | Please fork the project and submit a pull request for all bugfixes, patches, or suggested improvements for Gumshoe. 76 | 77 | Please take into consideration our formatting style when submitting pull requests. Pull requests which don't follow our simple style guide won't be accepted. 78 | 79 | - Indentation is 2 spaces, no tabs. 80 | - `var` blocks should be separated by newlines. 81 | - Strings should be single-quoted 82 | - Logical and functional blocks should have a newline after the opening brace or paren, and before the closing brace or paren. 83 | 84 | 85 | ## Support 86 | 87 | Please post support requests and bugs to the Github Issues page for this project. 88 | -------------------------------------------------------------------------------- /dist/gumshoe.js: -------------------------------------------------------------------------------- 1 | // polyfill for String.prototype.trim for IE8 2 | // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/Trim 3 | if (!String.prototype.trim) { 4 | (function() { 5 | // Make sure we trim BOM and NBSP 6 | var rtrim = /^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g; 7 | String.prototype.trim = function() { 8 | return this.replace(rtrim, ''); 9 | }; 10 | })(); 11 | } 12 | 13 | // Production steps of ECMA-262, Edition 5, 15.4.4.21 14 | // Reference: http://es5.github.io/#x15.4.4.21 15 | if (!Array.prototype.reduce) { 16 | Array.prototype.reduce = function(callback /*, initialValue*/) { 17 | 'use strict'; 18 | if (this == null) { 19 | throw new TypeError('Array.prototype.reduce called on null or undefined'); 20 | } 21 | if (typeof callback !== 'function') { 22 | throw new TypeError(callback + ' is not a function'); 23 | } 24 | var t = Object(this), len = t.length >>> 0, k = 0, value; 25 | if (arguments.length == 2) { 26 | value = arguments[1]; 27 | } else { 28 | while (k < len && ! k in t) { 29 | k++; 30 | } 31 | if (k >= len) { 32 | throw new TypeError('Reduce of empty array with no initial value'); 33 | } 34 | value = t[k++]; 35 | } 36 | for (; k < len; k++) { 37 | if (k in t) { 38 | value = callback(value, t[k], k, t); 39 | } 40 | } 41 | return value; 42 | }; 43 | } 44 | /** 45 | * @file perfnow is a 0.14 kb window.performance.now high resolution timer polyfill with Date fallback 46 | * @author Daniel Lamb 47 | */ 48 | (function perfnow (window) { 49 | // make sure we have an object to work with 50 | if (!('performance' in window)) { 51 | window.performance = {}; 52 | } 53 | var perf = window.performance; 54 | // handle vendor prefixing 55 | window.performance.now = perf.now || 56 | perf.mozNow || 57 | perf.msNow || 58 | perf.oNow || 59 | perf.webkitNow || 60 | // fallback to Date 61 | Date.now || function () { 62 | return new Date().getTime(); 63 | }; 64 | })(window); 65 | /* global performance */ 66 | (function (root) { 67 | 68 | 'use strict'; 69 | 70 | // we need reqwest and store2 (and any other future deps) 71 | // to be solely within our context, so as they don't leak and conflict 72 | // with other versions of the same libs sites may be loading. 73 | // so we'll provide our own context. 74 | // root._gumshoe is only available in specs 75 | var context = root._gumshoe || {}, 76 | queryString, 77 | store, 78 | /*jshint -W024 */ 79 | undefined; 80 | 81 | // call contextSetup with 'context' as 'this' so all libs attach 82 | // to our context variable. 83 | (function contextSetup () { 84 | /*! 85 | query-string 86 | Parse and stringify URL query strings 87 | https://github.com/sindresorhus/query-string 88 | by Sindre Sorhus 89 | MIT License 90 | */ 91 | (function (window) { 92 | 'use strict'; 93 | var queryString = {}; 94 | 95 | queryString.parse = function (str) { 96 | if (typeof str !== 'string') { 97 | return {}; 98 | } 99 | 100 | str = str.trim().replace(/^(\?|#)/, ''); 101 | 102 | if (!str) { 103 | return {}; 104 | } 105 | 106 | return str.trim().split('&').reduce(function (ret, param) { 107 | var parts = param.replace(/\+/g, ' ').split('='); 108 | var key = parts[0]; 109 | var val = parts[1]; 110 | 111 | key = decodeURIComponent(key); 112 | // missing `=` should be `null`: 113 | // http://w3.org/TR/2012/WD-url-20120524/#collect-url-parameters 114 | val = val === undefined ? null : decodeURIComponent(val); 115 | 116 | if (!ret.hasOwnProperty(key)) { 117 | ret[key] = val; 118 | } else if (Array.isArray(ret[key])) { 119 | ret[key].push(val); 120 | } else { 121 | ret[key] = [ret[key], val]; 122 | } 123 | 124 | return ret; 125 | }, {}); 126 | }; 127 | 128 | queryString.stringify = function (obj) { 129 | return obj ? Object.keys(obj).map(function (key) { 130 | var val = obj[key]; 131 | 132 | if (Array.isArray(val)) { 133 | return val.map(function (val2) { 134 | return encodeURIComponent(key) + '=' + encodeURIComponent(val2); 135 | }).join('&'); 136 | } 137 | 138 | return encodeURIComponent(key) + '=' + encodeURIComponent(val); 139 | }).join('&') : ''; 140 | }; 141 | 142 | if (typeof define === 'function' && define.amd) { 143 | define(function() { return queryString; }); 144 | } else if (typeof module !== 'undefined' && module.exports) { 145 | module.exports = queryString; 146 | } else { 147 | window.queryString = queryString; 148 | } 149 | })(this); 150 | 151 | 152 | /*! 153 | * Reqwest! A general purpose XHR connection manager 154 | * license MIT (c) Dustin Diaz 2014 155 | * https://github.com/ded/reqwest 156 | */ 157 | 158 | !function (name, context, definition) { 159 | if (typeof module != 'undefined' && module.exports) module.exports = definition() 160 | else if (typeof define == 'function' && define.amd) define(definition) 161 | else context[name] = definition() 162 | }('reqwest', this, function () { 163 | 164 | var win = window 165 | , doc = document 166 | , httpsRe = /^http/ 167 | , protocolRe = /(^\w+):\/\// 168 | , twoHundo = /^(20\d|1223)$/ //http://stackoverflow.com/questions/10046972/msie-returns-status-code-of-1223-for-ajax-request 169 | , byTag = 'getElementsByTagName' 170 | , readyState = 'readyState' 171 | , contentType = 'Content-Type' 172 | , requestedWith = 'X-Requested-With' 173 | , head = doc[byTag]('head')[0] 174 | , uniqid = 0 175 | , callbackPrefix = 'reqwest_' + (+new Date()) 176 | , lastValue // data stored by the most recent JSONP callback 177 | , xmlHttpRequest = 'XMLHttpRequest' 178 | , xDomainRequest = 'XDomainRequest' 179 | , noop = function () {} 180 | 181 | , isArray = typeof Array.isArray == 'function' 182 | ? Array.isArray 183 | : function (a) { 184 | return a instanceof Array 185 | } 186 | 187 | , defaultHeaders = { 188 | 'contentType': 'application/x-www-form-urlencoded' 189 | , 'requestedWith': xmlHttpRequest 190 | , 'accept': { 191 | '*': 'text/javascript, text/html, application/xml, text/xml, */*' 192 | , 'xml': 'application/xml, text/xml' 193 | , 'html': 'text/html' 194 | , 'text': 'text/plain' 195 | , 'json': 'application/json, text/javascript' 196 | , 'js': 'application/javascript, text/javascript' 197 | } 198 | } 199 | 200 | , xhr = function(o) { 201 | // is it x-domain 202 | if (o['crossOrigin'] === true) { 203 | var xhr = win[xmlHttpRequest] ? new XMLHttpRequest() : null 204 | if (xhr && 'withCredentials' in xhr) { 205 | return xhr 206 | } else if (win[xDomainRequest]) { 207 | return new XDomainRequest() 208 | } else { 209 | throw new Error('Browser does not support cross-origin requests') 210 | } 211 | } else if (win[xmlHttpRequest]) { 212 | return new XMLHttpRequest() 213 | } else { 214 | return new ActiveXObject('Microsoft.XMLHTTP') 215 | } 216 | } 217 | , globalSetupOptions = { 218 | dataFilter: function (data) { 219 | return data 220 | } 221 | } 222 | 223 | function succeed(r) { 224 | var protocol = protocolRe.exec(r.url); 225 | protocol = (protocol && protocol[1]) || window.location.protocol; 226 | return httpsRe.test(protocol) ? twoHundo.test(r.request.status) : !!r.request.responseText; 227 | } 228 | 229 | function handleReadyState(r, success, error) { 230 | return function () { 231 | // use _aborted to mitigate against IE err c00c023f 232 | // (can't read props on aborted request objects) 233 | if (r._aborted) return error(r.request) 234 | if (r._timedOut) return error(r.request, 'Request is aborted: timeout') 235 | if (r.request && r.request[readyState] == 4) { 236 | r.request.onreadystatechange = noop 237 | if (succeed(r)) success(r.request) 238 | else 239 | error(r.request) 240 | } 241 | } 242 | } 243 | 244 | function setHeaders(http, o) { 245 | var headers = o['headers'] || {} 246 | , h 247 | 248 | headers['Accept'] = headers['Accept'] 249 | || defaultHeaders['accept'][o['type']] 250 | || defaultHeaders['accept']['*'] 251 | 252 | var isAFormData = typeof FormData === 'function' && (o['data'] instanceof FormData); 253 | // breaks cross-origin requests with legacy browsers 254 | if (!o['crossOrigin'] && !headers[requestedWith]) headers[requestedWith] = defaultHeaders['requestedWith'] 255 | if (!headers[contentType] && !isAFormData) headers[contentType] = o['contentType'] || defaultHeaders['contentType'] 256 | for (h in headers) 257 | headers.hasOwnProperty(h) && 'setRequestHeader' in http && http.setRequestHeader(h, headers[h]) 258 | } 259 | 260 | function setCredentials(http, o) { 261 | if (typeof o['withCredentials'] !== 'undefined' && typeof http.withCredentials !== 'undefined') { 262 | http.withCredentials = !!o['withCredentials'] 263 | } 264 | } 265 | 266 | function generalCallback(data) { 267 | lastValue = data 268 | } 269 | 270 | function urlappend (url, s) { 271 | return url + (/\?/.test(url) ? '&' : '?') + s 272 | } 273 | 274 | function handleJsonp(o, fn, err, url) { 275 | var reqId = uniqid++ 276 | , cbkey = o['jsonpCallback'] || 'callback' // the 'callback' key 277 | , cbval = o['jsonpCallbackName'] || reqwest.getcallbackPrefix(reqId) 278 | , cbreg = new RegExp('((^|\\?|&)' + cbkey + ')=([^&]+)') 279 | , match = url.match(cbreg) 280 | , script = doc.createElement('script') 281 | , loaded = 0 282 | , isIE10 = navigator.userAgent.indexOf('MSIE 10.0') !== -1 283 | 284 | if (match) { 285 | if (match[3] === '?') { 286 | url = url.replace(cbreg, '$1=' + cbval) // wildcard callback func name 287 | } else { 288 | cbval = match[3] // provided callback func name 289 | } 290 | } else { 291 | url = urlappend(url, cbkey + '=' + cbval) // no callback details, add 'em 292 | } 293 | 294 | win[cbval] = generalCallback 295 | 296 | script.type = 'text/javascript' 297 | script.src = url 298 | script.async = true 299 | if (typeof script.onreadystatechange !== 'undefined' && !isIE10) { 300 | // need this for IE due to out-of-order onreadystatechange(), binding script 301 | // execution to an event listener gives us control over when the script 302 | // is executed. See http://jaubourg.net/2010/07/loading-script-as-onclick-handler-of.html 303 | script.htmlFor = script.id = '_reqwest_' + reqId 304 | } 305 | 306 | script.onload = script.onreadystatechange = function () { 307 | if ((script[readyState] && script[readyState] !== 'complete' && script[readyState] !== 'loaded') || loaded) { 308 | return false 309 | } 310 | script.onload = script.onreadystatechange = null 311 | script.onclick && script.onclick() 312 | // Call the user callback with the last value stored and clean up values and scripts. 313 | fn(lastValue) 314 | lastValue = undefined 315 | head.removeChild(script) 316 | loaded = 1 317 | } 318 | 319 | // Add the script to the DOM head 320 | head.appendChild(script) 321 | 322 | // Enable JSONP timeout 323 | return { 324 | abort: function () { 325 | script.onload = script.onreadystatechange = null 326 | err({}, 'Request is aborted: timeout', {}) 327 | lastValue = undefined 328 | head.removeChild(script) 329 | loaded = 1 330 | } 331 | } 332 | } 333 | 334 | function getRequest(fn, err) { 335 | var o = this.o 336 | , method = (o['method'] || 'GET').toUpperCase() 337 | , url = typeof o === 'string' ? o : o['url'] 338 | // convert non-string objects to query-string form unless o['processData'] is false 339 | , data = (o['processData'] !== false && o['data'] && typeof o['data'] !== 'string') 340 | ? reqwest.toQueryString(o['data']) 341 | : (o['data'] || null) 342 | , http 343 | , sendWait = false 344 | 345 | // if we're working on a GET request and we have data then we should append 346 | // query string to end of URL and not post data 347 | if ((o['type'] == 'jsonp' || method == 'GET') && data) { 348 | url = urlappend(url, data) 349 | data = null 350 | } 351 | 352 | if (o['type'] == 'jsonp') return handleJsonp(o, fn, err, url) 353 | 354 | // get the xhr from the factory if passed 355 | // if the factory returns null, fall-back to ours 356 | http = (o.xhr && o.xhr(o)) || xhr(o) 357 | 358 | http.open(method, url, o['async'] === false ? false : true) 359 | setHeaders(http, o) 360 | setCredentials(http, o) 361 | if (win[xDomainRequest] && http instanceof win[xDomainRequest]) { 362 | http.onload = fn 363 | http.onerror = err 364 | // NOTE: see 365 | // http://social.msdn.microsoft.com/Forums/en-US/iewebdevelopment/thread/30ef3add-767c-4436-b8a9-f1ca19b4812e 366 | http.onprogress = function() {} 367 | sendWait = true 368 | } else { 369 | http.onreadystatechange = handleReadyState(this, fn, err) 370 | } 371 | o['before'] && o['before'](http) 372 | if (sendWait) { 373 | setTimeout(function () { 374 | http.send(data) 375 | }, 200) 376 | } else { 377 | http.send(data) 378 | } 379 | return http 380 | } 381 | 382 | function Reqwest(o, fn) { 383 | this.o = o 384 | this.fn = fn 385 | 386 | init.apply(this, arguments) 387 | } 388 | 389 | function setType(header) { 390 | // json, javascript, text/plain, text/html, xml 391 | if (header.match('json')) return 'json' 392 | if (header.match('javascript')) return 'js' 393 | if (header.match('text')) return 'html' 394 | if (header.match('xml')) return 'xml' 395 | } 396 | 397 | function init(o, fn) { 398 | 399 | this.url = typeof o == 'string' ? o : o['url'] 400 | this.timeout = null 401 | 402 | // whether request has been fulfilled for purpose 403 | // of tracking the Promises 404 | this._fulfilled = false 405 | // success handlers 406 | this._successHandler = function(){} 407 | this._fulfillmentHandlers = [] 408 | // error handlers 409 | this._errorHandlers = [] 410 | // complete (both success and fail) handlers 411 | this._completeHandlers = [] 412 | this._erred = false 413 | this._responseArgs = {} 414 | 415 | var self = this 416 | 417 | fn = fn || function () {} 418 | 419 | if (o['timeout']) { 420 | this.timeout = setTimeout(function () { 421 | timedOut() 422 | }, o['timeout']) 423 | } 424 | 425 | if (o['success']) { 426 | this._successHandler = function () { 427 | o['success'].apply(o, arguments) 428 | } 429 | } 430 | 431 | if (o['error']) { 432 | this._errorHandlers.push(function () { 433 | o['error'].apply(o, arguments) 434 | }) 435 | } 436 | 437 | if (o['complete']) { 438 | this._completeHandlers.push(function () { 439 | o['complete'].apply(o, arguments) 440 | }) 441 | } 442 | 443 | function complete (resp) { 444 | o['timeout'] && clearTimeout(self.timeout) 445 | self.timeout = null 446 | while (self._completeHandlers.length > 0) { 447 | self._completeHandlers.shift()(resp) 448 | } 449 | } 450 | 451 | function success (resp) { 452 | var type = o['type'] || resp && setType(resp.getResponseHeader('Content-Type')) // resp can be undefined in IE 453 | resp = (type !== 'jsonp') ? self.request : resp 454 | // use global data filter on response text 455 | var filteredResponse = globalSetupOptions.dataFilter(resp.responseText, type) 456 | , r = filteredResponse 457 | try { 458 | resp.responseText = r 459 | } catch (e) { 460 | // can't assign this in IE<=8, just ignore 461 | } 462 | if (r) { 463 | switch (type) { 464 | case 'json': 465 | try { 466 | resp = win.JSON ? win.JSON.parse(r) : eval('(' + r + ')') 467 | } catch (err) { 468 | return error(resp, 'Could not parse JSON in response', err) 469 | } 470 | break 471 | case 'js': 472 | resp = eval(r) 473 | break 474 | case 'html': 475 | resp = r 476 | break 477 | case 'xml': 478 | resp = resp.responseXML 479 | && resp.responseXML.parseError // IE trololo 480 | && resp.responseXML.parseError.errorCode 481 | && resp.responseXML.parseError.reason 482 | ? null 483 | : resp.responseXML 484 | break 485 | } 486 | } 487 | 488 | self._responseArgs.resp = resp 489 | self._fulfilled = true 490 | fn(resp) 491 | self._successHandler(resp) 492 | while (self._fulfillmentHandlers.length > 0) { 493 | resp = self._fulfillmentHandlers.shift()(resp) 494 | } 495 | 496 | complete(resp) 497 | } 498 | 499 | function timedOut() { 500 | self._timedOut = true 501 | if(typeof self.request !== 'undefined' && typeof self.request.abort === 'function') { 502 | self.request.abort(); 503 | } 504 | } 505 | 506 | function error(resp, msg, t) { 507 | resp = self.request 508 | self._responseArgs.resp = resp 509 | self._responseArgs.msg = msg 510 | self._responseArgs.t = t 511 | self._erred = true 512 | while (self._errorHandlers.length > 0) { 513 | self._errorHandlers.shift()(resp, msg, t) 514 | } 515 | complete(resp) 516 | } 517 | 518 | this.request = getRequest.call(this, success, error) 519 | } 520 | 521 | Reqwest.prototype = { 522 | abort: function () { 523 | this._aborted = true 524 | if(typeof this.request !== 'undefined' && typeof this.request.abort === 'function') { 525 | this.request.abort(); 526 | } 527 | } 528 | 529 | , retry: function () { 530 | this._aborted=false; 531 | this._timedOut=false; 532 | init.call(this, this.o, this.fn) 533 | } 534 | 535 | /** 536 | * Small deviation from the Promises A CommonJs specification 537 | * http://wiki.commonjs.org/wiki/Promises/A 538 | */ 539 | 540 | /** 541 | * `then` will execute upon successful requests 542 | */ 543 | , then: function (success, fail) { 544 | success = success || function () {} 545 | fail = fail || function () {} 546 | if (this._fulfilled) { 547 | this._responseArgs.resp = success(this._responseArgs.resp) 548 | } else if (this._erred) { 549 | fail(this._responseArgs.resp, this._responseArgs.msg, this._responseArgs.t) 550 | } else { 551 | this._fulfillmentHandlers.push(success) 552 | this._errorHandlers.push(fail) 553 | } 554 | return this 555 | } 556 | 557 | /** 558 | * `always` will execute whether the request succeeds or fails 559 | */ 560 | , always: function (fn) { 561 | if (this._fulfilled || this._erred) { 562 | fn(this._responseArgs.resp) 563 | } else { 564 | this._completeHandlers.push(fn) 565 | } 566 | return this 567 | } 568 | 569 | /** 570 | * `fail` will execute when the request fails 571 | */ 572 | , fail: function (fn) { 573 | if (this._erred) { 574 | fn(this._responseArgs.resp, this._responseArgs.msg, this._responseArgs.t) 575 | } else { 576 | this._errorHandlers.push(fn) 577 | } 578 | return this 579 | } 580 | , 'catch': function (fn) { 581 | return this.fail(fn) 582 | } 583 | } 584 | 585 | function reqwest(o, fn) { 586 | return new Reqwest(o, fn) 587 | } 588 | 589 | // normalize newline variants according to spec -> CRLF 590 | function normalize(s) { 591 | return s ? s.replace(/\r?\n/g, '\r\n') : '' 592 | } 593 | 594 | function serial(el, cb) { 595 | var n = el.name 596 | , t = el.tagName.toLowerCase() 597 | , optCb = function (o) { 598 | // IE gives value="" even where there is no value attribute 599 | // 'specified' ref: http://www.w3.org/TR/DOM-Level-3-Core/core.html#ID-862529273 600 | if (o && !o['disabled']) 601 | cb(n, normalize(o['attributes']['value'] && o['attributes']['value']['specified'] ? o['value'] : o['text'])) 602 | } 603 | , ch, ra, val, i 604 | 605 | // don't serialize elements that are disabled or without a name 606 | if (el.disabled || !n) return 607 | 608 | switch (t) { 609 | case 'input': 610 | if (!/reset|button|image|file/i.test(el.type)) { 611 | ch = /checkbox/i.test(el.type) 612 | ra = /radio/i.test(el.type) 613 | val = el.value 614 | // WebKit gives us "" instead of "on" if a checkbox has no value, so correct it here 615 | ;(!(ch || ra) || el.checked) && cb(n, normalize(ch && val === '' ? 'on' : val)) 616 | } 617 | break 618 | case 'textarea': 619 | cb(n, normalize(el.value)) 620 | break 621 | case 'select': 622 | if (el.type.toLowerCase() === 'select-one') { 623 | optCb(el.selectedIndex >= 0 ? el.options[el.selectedIndex] : null) 624 | } else { 625 | for (i = 0; el.length && i < el.length; i++) { 626 | el.options[i].selected && optCb(el.options[i]) 627 | } 628 | } 629 | break 630 | } 631 | } 632 | 633 | // collect up all form elements found from the passed argument elements all 634 | // the way down to child elements; pass a '
' or form fields. 635 | // called with 'this'=callback to use for serial() on each element 636 | function eachFormElement() { 637 | var cb = this 638 | , e, i 639 | , serializeSubtags = function (e, tags) { 640 | var i, j, fa 641 | for (i = 0; i < tags.length; i++) { 642 | fa = e[byTag](tags[i]) 643 | for (j = 0; j < fa.length; j++) serial(fa[j], cb) 644 | } 645 | } 646 | 647 | for (i = 0; i < arguments.length; i++) { 648 | e = arguments[i] 649 | if (/input|select|textarea/i.test(e.tagName)) serial(e, cb) 650 | serializeSubtags(e, [ 'input', 'select', 'textarea' ]) 651 | } 652 | } 653 | 654 | // standard query string style serialization 655 | function serializeQueryString() { 656 | return reqwest.toQueryString(reqwest.serializeArray.apply(null, arguments)) 657 | } 658 | 659 | // { 'name': 'value', ... } style serialization 660 | function serializeHash() { 661 | var hash = {} 662 | eachFormElement.apply(function (name, value) { 663 | if (name in hash) { 664 | hash[name] && !isArray(hash[name]) && (hash[name] = [hash[name]]) 665 | hash[name].push(value) 666 | } else hash[name] = value 667 | }, arguments) 668 | return hash 669 | } 670 | 671 | // [ { name: 'name', value: 'value' }, ... ] style serialization 672 | reqwest.serializeArray = function () { 673 | var arr = [] 674 | eachFormElement.apply(function (name, value) { 675 | arr.push({name: name, value: value}) 676 | }, arguments) 677 | return arr 678 | } 679 | 680 | reqwest.serialize = function () { 681 | if (arguments.length === 0) return '' 682 | var opt, fn 683 | , args = Array.prototype.slice.call(arguments, 0) 684 | 685 | opt = args.pop() 686 | opt && opt.nodeType && args.push(opt) && (opt = null) 687 | opt && (opt = opt.type) 688 | 689 | if (opt == 'map') fn = serializeHash 690 | else if (opt == 'array') fn = reqwest.serializeArray 691 | else fn = serializeQueryString 692 | 693 | return fn.apply(null, args) 694 | } 695 | 696 | reqwest.toQueryString = function (o, trad) { 697 | var prefix, i 698 | , traditional = trad || false 699 | , s = [] 700 | , enc = encodeURIComponent 701 | , add = function (key, value) { 702 | // If value is a function, invoke it and return its value 703 | value = ('function' === typeof value) ? value() : (value == null ? '' : value) 704 | s[s.length] = enc(key) + '=' + enc(value) 705 | } 706 | // If an array was passed in, assume that it is an array of form elements. 707 | if (isArray(o)) { 708 | for (i = 0; o && i < o.length; i++) add(o[i]['name'], o[i]['value']) 709 | } else { 710 | // If traditional, encode the "old" way (the way 1.3.2 or older 711 | // did it), otherwise encode params recursively. 712 | for (prefix in o) { 713 | if (o.hasOwnProperty(prefix)) buildParams(prefix, o[prefix], traditional, add) 714 | } 715 | } 716 | 717 | // spaces should be + according to spec 718 | return s.join('&').replace(/%20/g, '+') 719 | } 720 | 721 | function buildParams(prefix, obj, traditional, add) { 722 | var name, i, v 723 | , rbracket = /\[\]$/ 724 | 725 | if (isArray(obj)) { 726 | // Serialize array item. 727 | for (i = 0; obj && i < obj.length; i++) { 728 | v = obj[i] 729 | if (traditional || rbracket.test(prefix)) { 730 | // Treat each array item as a scalar. 731 | add(prefix, v) 732 | } else { 733 | buildParams(prefix + '[' + (typeof v === 'object' ? i : '') + ']', v, traditional, add) 734 | } 735 | } 736 | } else if (obj && obj.toString() === '[object Object]') { 737 | // Serialize object item. 738 | for (name in obj) { 739 | buildParams(prefix + '[' + name + ']', obj[name], traditional, add) 740 | } 741 | 742 | } else { 743 | // Serialize scalar item. 744 | add(prefix, obj) 745 | } 746 | } 747 | 748 | reqwest.getcallbackPrefix = function () { 749 | return callbackPrefix 750 | } 751 | 752 | // jQuery and Zepto compatibility, differences can be remapped here so you can call 753 | // .ajax.compat(options, callback) 754 | reqwest.compat = function (o, fn) { 755 | if (o) { 756 | o['type'] && (o['method'] = o['type']) && delete o['type'] 757 | o['dataType'] && (o['type'] = o['dataType']) 758 | o['jsonpCallback'] && (o['jsonpCallbackName'] = o['jsonpCallback']) && delete o['jsonpCallback'] 759 | o['jsonp'] && (o['jsonpCallback'] = o['jsonp']) 760 | } 761 | return new Reqwest(o, fn) 762 | } 763 | 764 | reqwest.ajaxSetup = function (options) { 765 | options = options || {} 766 | for (var k in options) { 767 | globalSetupOptions[k] = options[k] 768 | } 769 | } 770 | 771 | return reqwest 772 | }); 773 | 774 | 775 | /*! store2 - v2.3.0 - 2015-05-22 776 | * Copyright (c) 2015 Nathan Bubna; Licensed MIT, GPL */ 777 | ;(function(window, define) { 778 | var _ = { 779 | version: "2.3.0", 780 | areas: {}, 781 | apis: {}, 782 | 783 | // utilities 784 | inherit: function(api, o) { 785 | for (var p in api) { 786 | if (!o.hasOwnProperty(p)) { 787 | o[p] = api[p]; 788 | } 789 | } 790 | return o; 791 | }, 792 | stringify: function(d) { 793 | return d === undefined || typeof d === "function" ? d+'' : JSON.stringify(d); 794 | }, 795 | parse: function(s) { 796 | // if it doesn't parse, return as is 797 | try{ return JSON.parse(s); }catch(e){ return s; } 798 | }, 799 | 800 | // extension hooks 801 | fn: function(name, fn) { 802 | _.storeAPI[name] = fn; 803 | for (var api in _.apis) { 804 | _.apis[api][name] = fn; 805 | } 806 | }, 807 | get: function(area, key){ return area.getItem(key); }, 808 | set: function(area, key, string){ area.setItem(key, string); }, 809 | remove: function(area, key){ area.removeItem(key); }, 810 | key: function(area, i){ return area.key(i); }, 811 | length: function(area){ return area.length; }, 812 | clear: function(area){ area.clear(); }, 813 | 814 | // core functions 815 | Store: function(id, area, namespace) { 816 | var store = _.inherit(_.storeAPI, function(key, data, overwrite) { 817 | if (arguments.length === 0){ return store.getAll(); } 818 | if (data !== undefined){ return store.set(key, data, overwrite); } 819 | if (typeof key === "string"){ return store.get(key); } 820 | if (!key){ return store.clear(); } 821 | return store.setAll(key, data);// overwrite=data, data=key 822 | }); 823 | store._id = id; 824 | try { 825 | var testKey = '_safariPrivate_'; 826 | area.setItem(testKey, 'sucks'); 827 | store._area = area; 828 | area.removeItem(testKey); 829 | } catch (e) {} 830 | if (!store._area) { 831 | store._area = _.inherit(_.storageAPI, { items: {}, name: 'fake' }); 832 | } 833 | store._ns = namespace || ''; 834 | if (!_.areas[id]) { 835 | _.areas[id] = store._area; 836 | } 837 | if (!_.apis[store._ns+store._id]) { 838 | _.apis[store._ns+store._id] = store; 839 | } 840 | return store; 841 | }, 842 | storeAPI: { 843 | // admin functions 844 | area: function(id, area) { 845 | var store = this[id]; 846 | if (!store || !store.area) { 847 | store = _.Store(id, area, this._ns);//new area-specific api in this namespace 848 | if (!this[id]){ this[id] = store; } 849 | } 850 | return store; 851 | }, 852 | namespace: function(namespace, noSession) { 853 | if (!namespace){ 854 | return this._ns ? this._ns.substring(0,this._ns.length-1) : ''; 855 | } 856 | var ns = namespace, store = this[ns]; 857 | if (!store || !store.namespace) { 858 | store = _.Store(this._id, this._area, this._ns+ns+'.');//new namespaced api 859 | if (!this[ns]){ this[ns] = store; } 860 | if (!noSession){ store.area('session', _.areas.session); } 861 | } 862 | return store; 863 | }, 864 | isFake: function(){ return this._area.name === 'fake'; }, 865 | toString: function() { 866 | return 'store'+(this._ns?'.'+this.namespace():'')+'['+this._id+']'; 867 | }, 868 | 869 | // storage functions 870 | has: function(key) { 871 | if (this._area.has) { 872 | return this._area.has(this._in(key));//extension hook 873 | } 874 | return !!(this._in(key) in this._area); 875 | }, 876 | size: function(){ return this.keys().length; }, 877 | each: function(fn, and) { 878 | for (var i=0, m=_.length(this._area); i _.length(this._area)) { m--; i--; }// in case of removeItem 886 | } 887 | return and || this; 888 | }, 889 | keys: function() { 890 | return this.each(function(k, list){ list.push(k); }, []); 891 | }, 892 | get: function(key, alt) { 893 | var s = _.get(this._area, this._in(key)); 894 | return s !== null ? _.parse(s) : alt || s;// support alt for easy default mgmt 895 | }, 896 | getAll: function() { 897 | return this.each(function(k, all){ all[k] = this.get(k); }, {}); 898 | }, 899 | set: function(key, data, overwrite) { 900 | var d = this.get(key); 901 | if (d != null && overwrite === false) { 902 | return data; 903 | } 904 | return _.set(this._area, this._in(key), _.stringify(data), overwrite) || d; 905 | }, 906 | setAll: function(data, overwrite) { 907 | var changed, val; 908 | for (var key in data) { 909 | val = data[key]; 910 | if (this.set(key, val, overwrite) !== val) { 911 | changed = true; 912 | } 913 | } 914 | return changed; 915 | }, 916 | remove: function(key) { 917 | var d = this.get(key); 918 | _.remove(this._area, this._in(key)); 919 | return d; 920 | }, 921 | clear: function() { 922 | if (!this._ns) { 923 | _.clear(this._area); 924 | } else { 925 | this.each(function(k){ _.remove(this._area, this._in(k)); }, 1); 926 | } 927 | return this; 928 | }, 929 | clearAll: function() { 930 | var area = this._area; 931 | for (var id in _.areas) { 932 | if (_.areas.hasOwnProperty(id)) { 933 | this._area = _.areas[id]; 934 | this.clear(); 935 | } 936 | } 937 | this._area = area; 938 | return this; 939 | }, 940 | 941 | // internal use functions 942 | _in: function(k) { 943 | if (typeof k !== "string"){ k = _.stringify(k); } 944 | return this._ns ? this._ns + k : k; 945 | }, 946 | _out: function(k) { 947 | return this._ns ? 948 | k && k.indexOf(this._ns) === 0 ? 949 | k.substring(this._ns.length) : 950 | undefined : // so each() knows to skip it 951 | k; 952 | } 953 | },// end _.storeAPI 954 | storageAPI: { 955 | length: 0, 956 | has: function(k){ return this.items.hasOwnProperty(k); }, 957 | key: function(i) { 958 | var c = 0; 959 | for (var k in this.items){ 960 | if (this.has(k) && i === c++) { 961 | return k; 962 | } 963 | } 964 | }, 965 | setItem: function(k, v) { 966 | if (!this.has(k)) { 967 | this.length++; 968 | } 969 | this.items[k] = v; 970 | }, 971 | removeItem: function(k) { 972 | if (this.has(k)) { 973 | delete this.items[k]; 974 | this.length--; 975 | } 976 | }, 977 | getItem: function(k){ return this.has(k) ? this.items[k] : null; }, 978 | clear: function(){ for (var k in this.list){ this.removeItem(k); } }, 979 | toString: function(){ return this.length+' items in '+this.name+'Storage'; } 980 | }// end _.storageAPI 981 | }; 982 | 983 | // setup the primary store fn 984 | if (window.store){ _.conflict = window.store; } 985 | var store = 986 | // safely set this up (throws error in IE10/32bit mode for local files) 987 | _.Store("local", (function(){try{ return localStorage; }catch(e){}})()); 988 | store.local = store;// for completeness 989 | store._ = _;// for extenders and debuggers... 990 | // safely setup store.session (throws exception in FF for file:/// urls) 991 | store.area("session", (function(){try{ return sessionStorage; }catch(e){}})()); 992 | 993 | //Expose store to the global object 994 | window.store = store; 995 | 996 | if (typeof define === 'function' && define.amd !== undefined) { 997 | define(function () { 998 | return store; 999 | }); 1000 | } else if (typeof module !== 'undefined' && module.exports) { 1001 | module.exports = store; 1002 | } 1003 | 1004 | })(this, this.define); 1005 | 1006 | 1007 | }).call(context); 1008 | 1009 | queryString = context.queryString; 1010 | store = context.store; 1011 | 1012 | function extend (obj) { 1013 | if (!isObject(obj)) { 1014 | return obj; 1015 | } 1016 | var source, prop; 1017 | for (var i = 1, length = arguments.length; i < length; i++) { 1018 | source = arguments[i]; 1019 | for (prop in source) { 1020 | obj[prop] = source[prop]; 1021 | } 1022 | } 1023 | return obj; 1024 | } 1025 | 1026 | function isArray (obj) { 1027 | return '[object Array]' === Object.prototype.toString.call(obj); 1028 | } 1029 | 1030 | function isFunction (obj) { 1031 | return ('' + typeof obj) === 'function'; 1032 | } 1033 | 1034 | function isObject (obj) { 1035 | var type = typeof obj; 1036 | return type === 'function' || type === 'object' && !!obj; 1037 | } 1038 | 1039 | function isString (value) { 1040 | return typeof value == 'string' || (value && typeof value == 'object' && 1041 | Object.prototype.toString.call(value) == '[object String]') || false; 1042 | } 1043 | 1044 | function uuidv4 (){ 1045 | var d = performance.now(); 1046 | var uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { 1047 | var r = (d + Math.random()*16)%16 | 0; 1048 | d = Math.floor(d/16); 1049 | return (c=='x' ? r : (r&0x3|0x8)).toString(16); 1050 | }); 1051 | return uuid; 1052 | } 1053 | 1054 | var defaults = { 1055 | transport: '', 1056 | queueTimeout: 100 1057 | }, 1058 | localStore = store.namespace('gumshoe'), 1059 | storage = store.namespace('gumshoe').session, 1060 | queue = storage('queue') || [], 1061 | transports = {}; 1062 | 1063 | if (!isArray(queue)) { 1064 | queue = []; 1065 | } 1066 | 1067 | function gumshoe (options) { 1068 | var clientUuid = localStore('clientUuid'); 1069 | 1070 | options = extend({}, defaults, options); 1071 | 1072 | // always ensure options.transport is an array. 1073 | if (isString(options.transport)) { 1074 | options.transport = [options.transport]; 1075 | } 1076 | else if (!isArray(options.transport)) { 1077 | throw 'Gumeshoe: Transport property must be a [String] or [Array].'; 1078 | } 1079 | 1080 | // store a client id to identify a client long-term. Google Analytics uses 1081 | // the value, combined with other factors, to determine unique users. we 1082 | // duplicate the same kind of value to assist GA. 1083 | if (!clientUuid) { 1084 | clientUuid = uuidv4(); 1085 | localStore({ clientUuid: clientUuid }); 1086 | } 1087 | 1088 | options.clientUuid = clientUuid; 1089 | 1090 | session(options.sessionFn); 1091 | 1092 | gumshoe.options = options; 1093 | } 1094 | 1095 | function each (obj, iterator, context) { 1096 | if (obj === null) { 1097 | return; 1098 | } 1099 | 1100 | if (Array.prototype.forEach && obj.forEach === Array.prototype.forEach) { 1101 | obj.forEach(iterator, context); 1102 | } 1103 | else if (obj.length === +obj.length) { 1104 | for (var i = 0, l = obj.length; i < l; i++) { 1105 | if (iterator.call(context, obj[i], i, obj) === {}) { 1106 | return; 1107 | } 1108 | } 1109 | } 1110 | else { 1111 | for (var key in obj) { 1112 | if (obj.hasOwnProperty(key)) { 1113 | if (iterator.call(context, obj[key], key, obj) === {}) { 1114 | return; 1115 | } 1116 | } 1117 | } 1118 | } 1119 | } 1120 | 1121 | function map (obj, iterator, context) { 1122 | var results = []; 1123 | 1124 | if (!obj) { 1125 | return results; 1126 | } 1127 | 1128 | if (Array.prototype.map && obj.map === Array.prototype.map) { 1129 | return obj.map(iterator, context); 1130 | } 1131 | 1132 | each(obj, function(value, index, list) { 1133 | results[results.length] = iterator.call(context, value, index, list); 1134 | }); 1135 | 1136 | return results; 1137 | } 1138 | 1139 | function collectPlugins () { 1140 | var result, 1141 | plugins = navigator.plugins || []; 1142 | 1143 | result = map(plugins, function (plugin) { 1144 | var mimeTypes = map(plugin, function (mimeType) { 1145 | var type = mimeType.type; 1146 | 1147 | if (mimeType.suffixes) { 1148 | type += '~' + mimeType.suffixes; 1149 | } 1150 | 1151 | return type; 1152 | }); 1153 | 1154 | return { 1155 | description: plugin.description, 1156 | filename: plugin.filename, 1157 | mimeTypes: mimeTypes, 1158 | name: plugin.name 1159 | }; 1160 | }); 1161 | 1162 | return result; 1163 | } 1164 | 1165 | function collect () { 1166 | 1167 | function getViewport() { 1168 | var e = window, a = 'inner'; 1169 | if (!('innerWidth' in window )) { 1170 | a = 'client'; 1171 | e = document.documentElement || document.body; 1172 | } 1173 | return { width : e[ a+'Width' ] , height : e[ a+'Height' ] }; 1174 | } 1175 | 1176 | var 1177 | viewport = getViewport(), 1178 | 1179 | query = queryString.parse(location.search), 1180 | 1181 | result = { 1182 | // utmcs Character set (e.g. ISO-8859-1) 1183 | characterSet: document.characterSet || document.charset || document.inputEncoding || 'Unknown', 1184 | 1185 | // utmsc Screen colour depth (e.g. 24-bit) 1186 | colorDepth: screen.colorDepth + '', 1187 | 1188 | cookie: document.cookie, 1189 | 1190 | // gclid Gclid is a globally unique tracking parameter (Google Click Identifier) 1191 | googleClickId: query.gclid || '', 1192 | 1193 | hash: window.location.hash, 1194 | host: window.location.host, 1195 | 1196 | // utmhn Hostname 1197 | hostName: window.location.hostname, 1198 | 1199 | // utmip IP address 1200 | ipAddress: '', 1201 | 1202 | // utmje Java enabled? 1203 | javaEnabled: navigator.javaEnabled ? navigator.javaEnabled() : false, 1204 | 1205 | // utmul Language code (e.g. en-us) 1206 | language: document.documentElement ? document.documentElement.lang : window.navigator.language || 'Unknown', 1207 | 1208 | // login key: ?lk= 1209 | loginKey: query.lk || '', 1210 | 1211 | // IE9 doesn't support this 1212 | origin: window.location.origin || '', 1213 | 1214 | // utmp Page path 1215 | path: window.location.pathname, 1216 | platform: window.navigator.platform, 1217 | plugins: collectPlugins(), 1218 | port: window.location.port || 80, 1219 | // promotional key: pkey 1220 | promotionKey: query.pkey || '', 1221 | protocol: window.location.protocol, 1222 | 1223 | queryString: window.location.search, 1224 | 1225 | // utmr Full referral URL 1226 | referer: document.referrer, 1227 | 1228 | screenAvailHeight: screen.availHeight, 1229 | screenAvailWidth: screen.availWidth, 1230 | screenHeight: screen.height, 1231 | screenOrientationAngle: '', 1232 | screenOrientationType: '', 1233 | screenPixelDepth: screen.pixelDepth + '', 1234 | // utmsr Screen resolution 1235 | screenResolution: screen.width + 'x' + screen.height, 1236 | screenWidth: screen.width, 1237 | 1238 | // utmdt Page title 1239 | title: document.title, 1240 | 1241 | url: window.location.href, 1242 | userAgent: window.navigator.userAgent, 1243 | utmCampaign: query.utm_campaign || '', 1244 | utmContent: query.utm_content || '', 1245 | utmMedium: query.utm_medium || '', 1246 | utmSource: query.utm_source || '', 1247 | utmTerm: query.utm_term || '', 1248 | 1249 | // utmvp Viewport resolution 1250 | viewportHeight: viewport.height, 1251 | viewportResolution: viewport.width + 'x' + viewport.height, 1252 | viewportWidth: viewport.width 1253 | }, 1254 | 1255 | intFields = [ 1256 | 'port', 'screenAvailHeight', 'screenAvailWidth', 'screenHeight', 1257 | 'screenOrientationAngle', 'screenWidth', 'viewportHeight', 'viewportWidth' 1258 | ], 1259 | prop, 1260 | value; 1261 | 1262 | // some browsers don't support navigator.javaEnabled(), it's always undefined. 1263 | if (result.javaEnabled === undefined) { 1264 | result.javaEnabled = false; 1265 | } 1266 | 1267 | // IE 8, 9 don't support this. Yay. 1268 | if (screen.orientation) { 1269 | result.screenOrientationAngle = parseInt(screen.orientation.angle ? screen.orientation.angle : '0'); 1270 | result.screenOrientationType = screen.orientation.type ? screen.orientation.type : ''; 1271 | 1272 | if (isNaN(result.screenOrientationAngle)) { 1273 | result.screenOrientationAngle = 0; 1274 | } 1275 | } 1276 | 1277 | // assert that these values are ints 1278 | for (var i = 0; i < intFields.length; i++) { 1279 | prop = intFields[i]; 1280 | value = parseInt(result[prop]); 1281 | 1282 | if (isNaN(value)) { 1283 | value = 0; 1284 | } 1285 | 1286 | result[prop] = value; 1287 | } 1288 | 1289 | return result; 1290 | } 1291 | 1292 | /** 1293 | * @private 1294 | * @method session 1295 | * 1296 | * @note 1297 | * Gumshoe Session Rules 1298 | * 1299 | * Generate a new Session ID if any of the following criteria are met: 1300 | * 1301 | * 1. User opens new tab or window (browser default behavior) 1302 | * 2. User has been inactive longer than 30 minutes 1303 | * 3. User has visited withinin the same session, but a UTM 1304 | * query string parameter has changed. 1305 | */ 1306 | function session (fn) { 1307 | 1308 | // returns a simple object containing utm parameters 1309 | function getUtm () { 1310 | return { 1311 | campaign: query.utm_campaign || '', 1312 | medium: query.utm_medium || '', 1313 | source: query.utm_source || '', 1314 | utmTerm: query.utm_term || '' 1315 | }; 1316 | } 1317 | 1318 | var now = (new Date()).getTime(), 1319 | query = queryString.parse(location.search), 1320 | lastUtm = storage('utm') || getUtm(), 1321 | utm = getUtm(), 1322 | timestamp, 1323 | difference; 1324 | 1325 | // save the current state of the utm parameters 1326 | storage('utm', utm); 1327 | 1328 | // set a session based uuid 1329 | if (!storage('uuid')) { 1330 | storage('uuid', uuidv4()); 1331 | storage('timestamp', now); 1332 | } 1333 | else { 1334 | timestamp = storage('timestamp'); 1335 | difference = now - timestamp; 1336 | 1337 | if (fn) { 1338 | /* jshint validthis: true */ 1339 | if (fn.call(this, timestamp, difference, query)) { 1340 | storage('uuid', uuidv4()); 1341 | } 1342 | } 1343 | else if (JSON.stringify(lastUtm) !== JSON.stringify(utm) || difference > (1000 * 60 * 30)) { 1344 | storage('uuid', uuidv4()); 1345 | } 1346 | } 1347 | } 1348 | 1349 | function send (eventName, eventData) { 1350 | var pageData = collect(), 1351 | baseData = { 1352 | clientUuid: gumshoe.options.clientUuid, 1353 | eventName: eventName, 1354 | eventData: eventData || {}, 1355 | gumshoe: '0.8.1', 1356 | pageData: pageData, 1357 | sessionUuid: storage('uuid'), 1358 | timestamp: (new Date()).getTime(), 1359 | timezoneOffset: (new Date()).getTimezoneOffset(), 1360 | uuid: uuidv4() 1361 | }, 1362 | transportFound = false; 1363 | 1364 | // since we're dealing with timeouts now, we need to reassert the 1365 | // session ID for each event sent. 1366 | session(gumshoe.options.sessionFn); 1367 | 1368 | for(var i = 0; i < gumshoe.options.transport.length; i++) { 1369 | var transportName = gumshoe.options.transport[i], 1370 | transport, 1371 | data; 1372 | 1373 | if (transportName && transports[transportName]) { 1374 | transportFound = true; 1375 | transport = transports[transportName]; 1376 | 1377 | // allow each transport to extend the data with more information 1378 | // or transform it how they'd like. transports cannot however, 1379 | // modify eventData sent from the client. 1380 | data = transport.map ? transport.map(baseData) : baseData; 1381 | 1382 | // extend our data with whatever came back from the transport 1383 | data = extend(baseData, data); 1384 | 1385 | // TODO: remove this. gumshoe shouldn't care what format this is in 1386 | if (!isString(data.eventData)) { 1387 | data.eventData = JSON.stringify(data.eventData); 1388 | } 1389 | 1390 | // TODO: remove this. gumshoe shouldn't care what format this is in 1391 | if (!isString(data.pageData.plugins)) { 1392 | data.pageData.plugins = JSON.stringify(data.pageData.plugins); 1393 | } 1394 | 1395 | // TODO: remove this. temporary bugfix for apps 1396 | if (!data.pageData.ipAddress) { 1397 | data.pageData.ipAddress = ''; 1398 | } 1399 | 1400 | pushEvent(eventName, transportName, data); 1401 | } 1402 | else { 1403 | throw 'Gumshoe: The transport name: ' + transportName + ', doesn\'t map to a valid transport.'; 1404 | } 1405 | } 1406 | 1407 | if (!transportFound) { 1408 | throw 'Gumshoe: No valid transports were found.'; 1409 | } 1410 | } 1411 | 1412 | function nextEvent () { 1413 | 1414 | if (!queue.length) { 1415 | return; 1416 | } 1417 | 1418 | // granb the next event from the queue and remove it. 1419 | var nevent = queue.shift(), 1420 | transport = transports[nevent.transportName]; 1421 | 1422 | transport.send(nevent.data, function (err, result) { 1423 | // we care if an error was thrown, created, or captured 1424 | // if there is an error, add the item back into the queue 1425 | if (err) { 1426 | queue.push(nevent); 1427 | 1428 | console.warn('Gumshoe: Retrying. Error received from transport: ' + nevent.transportName + ', for event: ' + nevent.eventName); 1429 | } 1430 | 1431 | // put our newly modified queue in session storage 1432 | // we're doing this after we send the event to mitigate data loss 1433 | // in the event if the request doesn't complete before the page changes 1434 | storage('queue', queue); 1435 | }); 1436 | 1437 | if (transport.send.length === 1) { 1438 | // TEMP HACK: 0.1.x of tracking_api.gumshoe didn't accept a callback 1439 | // parameter, because we didn't care about the failure of an event. 1440 | // we need to support 0.1.x in the same old way while 0.2.x is being 1441 | // rolled out. 1442 | storage('queue', queue); 1443 | } 1444 | 1445 | setTimeout(nextEvent, gumshoe.options.queueTimeout); 1446 | } 1447 | 1448 | function pushEvent (eventName, transportName, data) { 1449 | 1450 | var transport; 1451 | 1452 | // if we're dealing with a fake storage object 1453 | // (eg. sessionStorage isn't available) then don't 1454 | // even bother queueing the data. 1455 | if (storage.isFake()) { 1456 | transport = transports[transportName]; 1457 | transport.send(data); 1458 | 1459 | return; 1460 | } 1461 | 1462 | // add the event data to the queue 1463 | queue.push({ 1464 | eventName: eventName, 1465 | transportName: transportName, 1466 | data: data 1467 | }); 1468 | 1469 | // put our newly modified queue in session storage 1470 | storage('queue', queue); 1471 | 1472 | setTimeout(nextEvent, gumshoe.options.queueTimeout); 1473 | } 1474 | 1475 | function transport (tp) { 1476 | if (!tp.name) { 1477 | throw 'Gumshoe: Transport [Object] must have a name defined.'; 1478 | } 1479 | 1480 | transports[tp.name] = tp; 1481 | } 1482 | 1483 | // setup some static properties 1484 | gumshoe.version = '0.8.1'; 1485 | gumshoe.options = {}; 1486 | 1487 | // setup some static methods 1488 | gumshoe.extend = extend; 1489 | gumshoe.reqwest = context.reqwest; 1490 | gumshoe.send = send; 1491 | gumshoe.transport = transport; 1492 | gumshoe.uuid = uuidv4; 1493 | 1494 | // setup some internal stuff for access 1495 | gumshoe._ = { 1496 | collect: collect, 1497 | localStorage: localStore, 1498 | queryString: queryString, 1499 | queue: queue, 1500 | storage: storage, 1501 | transports: transports 1502 | }; 1503 | 1504 | if (root.gumshoe) { 1505 | 1506 | if (root.gumshoe.ready) { 1507 | root.gumshoe.ready = gumshoe.ready = root.gumshoe.ready; 1508 | root.gumshoe = gumshoe; 1509 | 1510 | if (!isFunction(root.gumshoe.ready.resolve)) { 1511 | throw 'Gumshoe: gumshoe.ready was predefined, but is not a Promise/A deferred.'; 1512 | } 1513 | 1514 | root.gumshoe.ready.resolve(); 1515 | } 1516 | } 1517 | else { 1518 | root.gumshoe = gumshoe; 1519 | } 1520 | 1521 | })(this); 1522 | -------------------------------------------------------------------------------- /dist/gumshoe.min.js: -------------------------------------------------------------------------------- 1 | String.prototype.trim||!function(){var e=/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g;String.prototype.trim=function(){return this.replace(e,"")}}(),Array.prototype.reduce||(Array.prototype.reduce=function(e){"use strict";if(null==this)throw new TypeError("Array.prototype.reduce called on null or undefined");if("function"!=typeof e)throw new TypeError(e+" is not a function");var t,n=Object(this),r=n.length>>>0,s=0;if(2==arguments.length)t=arguments[1];else{for(;s=r)throw new TypeError("Reduce of empty array with no initial value");t=n[s++]}for(;s18e5)&&storage("uuid",uuidv4())):(storage("uuid",uuidv4()),storage("timestamp",s))}function send(e,t){var n=collect(),r={clientUuid:gumshoe.options.clientUuid,eventName:e,eventData:t||{},gumshoe:"0.8.1",pageData:n,sessionUuid:storage("uuid"),timestamp:(new Date).getTime(),timezoneOffset:(new Date).getTimezoneOffset(),uuid:uuidv4()},s=!1;session(gumshoe.options.sessionFn);for(var o=0;o"),pushEvent(e,u,a)}if(!s)throw"Gumshoe: No valid transports were found."}function nextEvent(){if(queue.length){var e=queue.shift(),t=transports[e.transportName];t.send(e.data,function(t,n){t&&(queue.push(e),console.warn("Gumshoe: Retrying. Error received from transport: "+e.transportName+", for event: "+e.eventName)),storage("queue",queue)}),1===t.send.length&&storage("queue",queue),setTimeout(nextEvent,gumshoe.options.queueTimeout)}}function pushEvent(e,t,n){var r;return storage.isFake()?(r=transports[t],void r.send(n)):(queue.push({eventName:e,transportName:t,data:n}),storage("queue",queue),void setTimeout(nextEvent,gumshoe.options.queueTimeout))}function transport(e){if(!e.name)throw"Gumshoe: Transport [Object] must have a name defined.";transports[e.name]=e}var context=root._gumshoe||{},queryString,store,undefined;(function contextSetup(){!function(e){var t={};t.parse=function(e){return"string"!=typeof e?{}:(e=e.trim().replace(/^(\?|#)/,""),e?e.trim().split("&").reduce(function(e,t){var n=t.replace(/\+/g," ").split("="),r=n[0],s=n[1];return r=decodeURIComponent(r),s=s===undefined?null:decodeURIComponent(s),e.hasOwnProperty(r)?Array.isArray(e[r])?e[r].push(s):e[r]=[e[r],s]:e[r]=s,e},{}):{})},t.stringify=function(e){return e?Object.keys(e).map(function(t){var n=e[t];return Array.isArray(n)?n.map(function(e){return encodeURIComponent(t)+"="+encodeURIComponent(e)}).join("&"):encodeURIComponent(t)+"="+encodeURIComponent(n)}).join("&"):""},"function"==typeof define&&define.amd?define(function(){return t}):"undefined"!=typeof module&&module.exports?module.exports=t:e.queryString=t}(this),!function(e,t,n){"undefined"!=typeof module&&module.exports?module.exports=n():"function"==typeof define&&define.amd?define(n):t[e]=n()}("reqwest",this,function(){function succeed(e){var t=protocolRe.exec(e.url);return t=t&&t[1]||window.location.protocol,httpsRe.test(t)?twoHundo.test(e.request.status):!!e.request.responseText}function handleReadyState(e,t,n){return function(){return e._aborted?n(e.request):e._timedOut?n(e.request,"Request is aborted: timeout"):void(e.request&&4==e.request[readyState]&&(e.request.onreadystatechange=noop,succeed(e)?t(e.request):n(e.request)))}}function setHeaders(e,t){var n,r=t.headers||{};r.Accept=r.Accept||defaultHeaders.accept[t.type]||defaultHeaders.accept["*"];var s="function"==typeof FormData&&t.data instanceof FormData;t.crossOrigin||r[requestedWith]||(r[requestedWith]=defaultHeaders.requestedWith),r[contentType]||s||(r[contentType]=t.contentType||defaultHeaders.contentType);for(n in r)r.hasOwnProperty(n)&&"setRequestHeader"in e&&e.setRequestHeader(n,r[n])}function setCredentials(e,t){"undefined"!=typeof t.withCredentials&&"undefined"!=typeof e.withCredentials&&(e.withCredentials=!!t.withCredentials)}function generalCallback(e){lastValue=e}function urlappend(e,t){return e+(/\?/.test(e)?"&":"?")+t}function handleJsonp(e,t,n,r){var s=uniqid++,o=e.jsonpCallback||"callback",i=e.jsonpCallbackName||reqwest.getcallbackPrefix(s),a=new RegExp("((^|\\?|&)"+o+")=([^&]+)"),u=r.match(a),c=doc.createElement("script"),l=0,p=navigator.userAgent.indexOf("MSIE 10.0")!==-1;return u?"?"===u[3]?r=r.replace(a,"$1="+i):i=u[3]:r=urlappend(r,o+"="+i),win[i]=generalCallback,c.type="text/javascript",c.src=r,c.async=!0,"undefined"==typeof c.onreadystatechange||p||(c.htmlFor=c.id="_reqwest_"+s),c.onload=c.onreadystatechange=function(){return!(c[readyState]&&"complete"!==c[readyState]&&"loaded"!==c[readyState]||l)&&(c.onload=c.onreadystatechange=null,c.onclick&&c.onclick(),t(lastValue),lastValue=undefined,head.removeChild(c),void(l=1))},head.appendChild(c),{abort:function(){c.onload=c.onreadystatechange=null,n({},"Request is aborted: timeout",{}),lastValue=undefined,head.removeChild(c),l=1}}}function getRequest(e,t){var n,r=this.o,s=(r.method||"GET").toUpperCase(),o="string"==typeof r?r:r.url,i=r.processData!==!1&&r.data&&"string"!=typeof r.data?reqwest.toQueryString(r.data):r.data||null,a=!1;return"jsonp"!=r.type&&"GET"!=s||!i||(o=urlappend(o,i),i=null),"jsonp"==r.type?handleJsonp(r,e,t,o):(n=r.xhr&&r.xhr(r)||xhr(r),n.open(s,o,r.async!==!1),setHeaders(n,r),setCredentials(n,r),win[xDomainRequest]&&n instanceof win[xDomainRequest]?(n.onload=e,n.onerror=t,n.onprogress=function(){},a=!0):n.onreadystatechange=handleReadyState(this,e,t),r.before&&r.before(n),a?setTimeout(function(){n.send(i)},200):n.send(i),n)}function Reqwest(e,t){this.o=e,this.fn=t,init.apply(this,arguments)}function setType(e){return e.match("json")?"json":e.match("javascript")?"js":e.match("text")?"html":e.match("xml")?"xml":void 0}function init(o,fn){function complete(e){for(o.timeout&&clearTimeout(self.timeout),self.timeout=null;self._completeHandlers.length>0;)self._completeHandlers.shift()(e)}function success(resp){var type=o.type||resp&&setType(resp.getResponseHeader("Content-Type"));resp="jsonp"!==type?self.request:resp;var filteredResponse=globalSetupOptions.dataFilter(resp.responseText,type),r=filteredResponse;try{resp.responseText=r}catch(e){}if(r)switch(type){case"json":try{resp=win.JSON?win.JSON.parse(r):eval("("+r+")")}catch(e){return error(resp,"Could not parse JSON in response",e)}break;case"js":resp=eval(r);break;case"html":resp=r;break;case"xml":resp=resp.responseXML&&resp.responseXML.parseError&&resp.responseXML.parseError.errorCode&&resp.responseXML.parseError.reason?null:resp.responseXML}for(self._responseArgs.resp=resp,self._fulfilled=!0,fn(resp),self._successHandler(resp);self._fulfillmentHandlers.length>0;)resp=self._fulfillmentHandlers.shift()(resp);complete(resp)}function timedOut(){self._timedOut=!0,"undefined"!=typeof self.request&&"function"==typeof self.request.abort&&self.request.abort()}function error(e,t,n){for(e=self.request,self._responseArgs.resp=e,self._responseArgs.msg=t,self._responseArgs.t=n,self._erred=!0;self._errorHandlers.length>0;)self._errorHandlers.shift()(e,t,n);complete(e)}this.url="string"==typeof o?o:o.url,this.timeout=null,this._fulfilled=!1,this._successHandler=function(){},this._fulfillmentHandlers=[],this._errorHandlers=[],this._completeHandlers=[],this._erred=!1,this._responseArgs={};var self=this;fn=fn||function(){},o.timeout&&(this.timeout=setTimeout(function(){timedOut()},o.timeout)),o.success&&(this._successHandler=function(){o.success.apply(o,arguments)}),o.error&&this._errorHandlers.push(function(){o.error.apply(o,arguments)}),o.complete&&this._completeHandlers.push(function(){o.complete.apply(o,arguments)}),this.request=getRequest.call(this,success,error)}function reqwest(e,t){return new Reqwest(e,t)}function normalize(e){return e?e.replace(/\r?\n/g,"\r\n"):""}function serial(e,t){var n,r,s,o,i=e.name,a=e.tagName.toLowerCase(),u=function(e){e&&!e.disabled&&t(i,normalize(e.attributes.value&&e.attributes.value.specified?e.value:e.text))};if(!e.disabled&&i)switch(a){case"input":/reset|button|image|file/i.test(e.type)||(n=/checkbox/i.test(e.type),r=/radio/i.test(e.type),s=e.value,(!(n||r)||e.checked)&&t(i,normalize(n&&""===s?"on":s)));break;case"textarea":t(i,normalize(e.value));break;case"select":if("select-one"===e.type.toLowerCase())u(e.selectedIndex>=0?e.options[e.selectedIndex]:null);else for(o=0;e.length&&on.length(this._area)&&(s--,r--)}return t||this},keys:function(){return this.each(function(e,t){t.push(e)},[])},get:function(e,t){var r=n.get(this._area,this._in(e));return null!==r?n.parse(r):t||r},getAll:function(){return this.each(function(e,t){t[e]=this.get(e)},{})},set:function(e,t,r){var s=this.get(e);return null!=s&&r===!1?t:n.set(this._area,this._in(e),n.stringify(t),r)||s},setAll:function(e,t){var n,r;for(var s in e)r=e[s],this.set(s,r,t)!==r&&(n=!0);return n},remove:function(e){var t=this.get(e);return n.remove(this._area,this._in(e)),t},clear:function(){return this._ns?this.each(function(e){n.remove(this._area,this._in(e))},1):n.clear(this._area),this},clearAll:function(){var e=this._area;for(var t in n.areas)n.areas.hasOwnProperty(t)&&(this._area=n.areas[t],this.clear());return this._area=e,this},_in:function(e){return"string"!=typeof e&&(e=n.stringify(e)),this._ns?this._ns+e:e},_out:function(e){return this._ns?e&&0===e.indexOf(this._ns)?e.substring(this._ns.length):undefined:e}},storageAPI:{length:0,has:function(e){return this.items.hasOwnProperty(e)},key:function(e){var t=0;for(var n in this.items)if(this.has(n)&&e===t++)return n},setItem:function(e,t){this.has(e)||this.length++,this.items[e]=t},removeItem:function(e){this.has(e)&&(delete this.items[e],this.length--)},getItem:function(e){return this.has(e)?this.items[e]:null},clear:function(){for(var e in this.list)this.removeItem(e)},toString:function(){return this.length+" items in "+this.name+"Storage"}}};e.store&&(n.conflict=e.store);var r=n.Store("local",function(){try{return localStorage}catch(e){}}());r.local=r,r._=n,r.area("session",function(){try{return sessionStorage}catch(e){}}()),e.store=r,"function"==typeof t&&t.amd!==undefined?t(function(){return r}):"undefined"!=typeof module&&module.exports&&(module.exports=r)}(this,this.define)}).call(context),queryString=context.queryString,store=context.store;var defaults={transport:"",queueTimeout:100},localStore=store.namespace("gumshoe"),storage=store.namespace("gumshoe").session,queue=storage("queue")||[],transports={};if(isArray(queue)||(queue=[]),gumshoe.version="0.8.1",gumshoe.options={},gumshoe.extend=extend,gumshoe.reqwest=context.reqwest,gumshoe.send=send,gumshoe.transport=transport,gumshoe.uuid=uuidv4,gumshoe._={collect:collect,localStorage:localStore,queryString:queryString,queue:queue,storage:storage,transports:transports},root.gumshoe){if(root.gumshoe.ready){if(root.gumshoe.ready=gumshoe.ready=root.gumshoe.ready,root.gumshoe=gumshoe,!isFunction(root.gumshoe.ready.resolve))throw"Gumshoe: gumshoe.ready was predefined, but is not a Promise/A deferred.";root.gumshoe.ready.resolve()}}else root.gumshoe=gumshoe}(this); -------------------------------------------------------------------------------- /examples/transports/transport-example.js: -------------------------------------------------------------------------------- 1 | /* global reqwest, store, gilt */ 2 | (function (root) { 3 | 4 | var gumshoe = root.gumshoe; 5 | 6 | gumshoe.transport({ 7 | 8 | name: 'example-transport', 9 | 10 | send: function (data) { 11 | console.log('Gumshoe: Test Transport: Sending...'); 12 | console.log(data); 13 | }, 14 | 15 | map: function (data) { 16 | return { 17 | customData: { 18 | someData: true 19 | }, 20 | ipAddress: '192.168.1.1' 21 | }; 22 | } 23 | 24 | }); 25 | 26 | })(this); 27 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var gulp = require('gulp'), 4 | addsrc = require('gulp-add-src'), 5 | concat = require('gulp-concat'), 6 | del = require('del'), 7 | jshint = require('gulp-jshint'), 8 | mochaPhantomJS = require('gulp-mocha-phantomjs'), 9 | rename = require('gulp-rename'), 10 | replace = require('gulp-replace'), 11 | uglify = require('gulp-uglify'), 12 | gfi = require('gulp-file-insert'), 13 | 14 | pkg = require('./package.json'); 15 | 16 | gulp.task('lint', function () { 17 | 18 | return gulp.src('src/**/*.js') 19 | .pipe(jshint('.jshintrc')) 20 | .pipe(jshint.reporter('jshint-stylish')) 21 | .pipe(jshint.reporter('fail')); 22 | }); 23 | 24 | gulp.task('test', ['build'], function () { 25 | return gulp.src(['test/**/*.html', '!test/**/*-dist.html']) 26 | .pipe(mochaPhantomJS({})); 27 | }); 28 | 29 | gulp.task('test-dist', ['build'], function () { 30 | return gulp.src(['test/**/*-dist.html']) 31 | .pipe(mochaPhantomJS({})); 32 | }); 33 | 34 | gulp.task('build', ['lint'], function () { 35 | 36 | del.sync(['dist/**/*.js']); 37 | 38 | return gulp.src('src/gumshoe.js') 39 | 40 | // replace our scoped deps 41 | .pipe(gfi({ 42 | '// query-string.js': 'lib/query-string.js', 43 | '// reqwest.js': 'lib/reqwest.js', 44 | '// store2.js': 'lib/store2.js' 45 | })) 46 | 47 | // insert the version 48 | .pipe(replace('{package_version}', pkg.version)) 49 | 50 | // add our transports. important that this happens afterwards 51 | .pipe(addsrc('src/transports/**/*.js')) 52 | 53 | // prepend our required libs 54 | .pipe(addsrc.prepend([ 55 | 'lib/**/*.js', 56 | '!lib/**/query-string.js', 57 | '!lib/**/reqwest.js', 58 | '!lib/**/store2.js', 59 | ])) 60 | 61 | // combine all of the files into one and output 62 | .pipe(concat('gumshoe.js')) 63 | .pipe(gulp.dest('dist')) 64 | 65 | // minify and output 66 | .pipe(uglify()) 67 | .pipe(rename('gumshoe.min.js')) 68 | .pipe(gulp.dest('dist')); 69 | }); 70 | -------------------------------------------------------------------------------- /lib/_polyfills.js: -------------------------------------------------------------------------------- 1 | // polyfill for String.prototype.trim for IE8 2 | // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/Trim 3 | if (!String.prototype.trim) { 4 | (function() { 5 | // Make sure we trim BOM and NBSP 6 | var rtrim = /^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g; 7 | String.prototype.trim = function() { 8 | return this.replace(rtrim, ''); 9 | }; 10 | })(); 11 | } 12 | 13 | // Production steps of ECMA-262, Edition 5, 15.4.4.21 14 | // Reference: http://es5.github.io/#x15.4.4.21 15 | if (!Array.prototype.reduce) { 16 | Array.prototype.reduce = function(callback /*, initialValue*/) { 17 | 'use strict'; 18 | if (this == null) { 19 | throw new TypeError('Array.prototype.reduce called on null or undefined'); 20 | } 21 | if (typeof callback !== 'function') { 22 | throw new TypeError(callback + ' is not a function'); 23 | } 24 | var t = Object(this), len = t.length >>> 0, k = 0, value; 25 | if (arguments.length == 2) { 26 | value = arguments[1]; 27 | } else { 28 | while (k < len && ! k in t) { 29 | k++; 30 | } 31 | if (k >= len) { 32 | throw new TypeError('Reduce of empty array with no initial value'); 33 | } 34 | value = t[k++]; 35 | } 36 | for (; k < len; k++) { 37 | if (k in t) { 38 | value = callback(value, t[k], k, t); 39 | } 40 | } 41 | return value; 42 | }; 43 | } -------------------------------------------------------------------------------- /lib/perfnow.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file perfnow is a 0.14 kb window.performance.now high resolution timer polyfill with Date fallback 3 | * @author Daniel Lamb 4 | */ 5 | (function perfnow (window) { 6 | // make sure we have an object to work with 7 | if (!('performance' in window)) { 8 | window.performance = {}; 9 | } 10 | var perf = window.performance; 11 | // handle vendor prefixing 12 | window.performance.now = perf.now || 13 | perf.mozNow || 14 | perf.msNow || 15 | perf.oNow || 16 | perf.webkitNow || 17 | // fallback to Date 18 | Date.now || function () { 19 | return new Date().getTime(); 20 | }; 21 | })(window); -------------------------------------------------------------------------------- /lib/query-string.js: -------------------------------------------------------------------------------- 1 | /*! 2 | query-string 3 | Parse and stringify URL query strings 4 | https://github.com/sindresorhus/query-string 5 | by Sindre Sorhus 6 | MIT License 7 | */ 8 | (function (window) { 9 | 'use strict'; 10 | var queryString = {}; 11 | 12 | queryString.parse = function (str) { 13 | if (typeof str !== 'string') { 14 | return {}; 15 | } 16 | 17 | str = str.trim().replace(/^(\?|#)/, ''); 18 | 19 | if (!str) { 20 | return {}; 21 | } 22 | 23 | return str.trim().split('&').reduce(function (ret, param) { 24 | var parts = param.replace(/\+/g, ' ').split('='); 25 | var key = parts[0]; 26 | var val = parts[1]; 27 | 28 | key = decodeURIComponent(key); 29 | // missing `=` should be `null`: 30 | // http://w3.org/TR/2012/WD-url-20120524/#collect-url-parameters 31 | val = val === undefined ? null : decodeURIComponent(val); 32 | 33 | if (!ret.hasOwnProperty(key)) { 34 | ret[key] = val; 35 | } else if (Array.isArray(ret[key])) { 36 | ret[key].push(val); 37 | } else { 38 | ret[key] = [ret[key], val]; 39 | } 40 | 41 | return ret; 42 | }, {}); 43 | }; 44 | 45 | queryString.stringify = function (obj) { 46 | return obj ? Object.keys(obj).map(function (key) { 47 | var val = obj[key]; 48 | 49 | if (Array.isArray(val)) { 50 | return val.map(function (val2) { 51 | return encodeURIComponent(key) + '=' + encodeURIComponent(val2); 52 | }).join('&'); 53 | } 54 | 55 | return encodeURIComponent(key) + '=' + encodeURIComponent(val); 56 | }).join('&') : ''; 57 | }; 58 | 59 | if (typeof define === 'function' && define.amd) { 60 | define(function() { return queryString; }); 61 | } else if (typeof module !== 'undefined' && module.exports) { 62 | module.exports = queryString; 63 | } else { 64 | window.queryString = queryString; 65 | } 66 | })(this); 67 | -------------------------------------------------------------------------------- /lib/reqwest.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Reqwest! A general purpose XHR connection manager 3 | * license MIT (c) Dustin Diaz 2014 4 | * https://github.com/ded/reqwest 5 | */ 6 | 7 | !function (name, context, definition) { 8 | if (typeof module != 'undefined' && module.exports) module.exports = definition() 9 | else if (typeof define == 'function' && define.amd) define(definition) 10 | else context[name] = definition() 11 | }('reqwest', this, function () { 12 | 13 | var win = window 14 | , doc = document 15 | , httpsRe = /^http/ 16 | , protocolRe = /(^\w+):\/\// 17 | , twoHundo = /^(20\d|1223)$/ //http://stackoverflow.com/questions/10046972/msie-returns-status-code-of-1223-for-ajax-request 18 | , byTag = 'getElementsByTagName' 19 | , readyState = 'readyState' 20 | , contentType = 'Content-Type' 21 | , requestedWith = 'X-Requested-With' 22 | , head = doc[byTag]('head')[0] 23 | , uniqid = 0 24 | , callbackPrefix = 'reqwest_' + (+new Date()) 25 | , lastValue // data stored by the most recent JSONP callback 26 | , xmlHttpRequest = 'XMLHttpRequest' 27 | , xDomainRequest = 'XDomainRequest' 28 | , noop = function () {} 29 | 30 | , isArray = typeof Array.isArray == 'function' 31 | ? Array.isArray 32 | : function (a) { 33 | return a instanceof Array 34 | } 35 | 36 | , defaultHeaders = { 37 | 'contentType': 'application/x-www-form-urlencoded' 38 | , 'requestedWith': xmlHttpRequest 39 | , 'accept': { 40 | '*': 'text/javascript, text/html, application/xml, text/xml, */*' 41 | , 'xml': 'application/xml, text/xml' 42 | , 'html': 'text/html' 43 | , 'text': 'text/plain' 44 | , 'json': 'application/json, text/javascript' 45 | , 'js': 'application/javascript, text/javascript' 46 | } 47 | } 48 | 49 | , xhr = function(o) { 50 | // is it x-domain 51 | if (o['crossOrigin'] === true) { 52 | var xhr = win[xmlHttpRequest] ? new XMLHttpRequest() : null 53 | if (xhr && 'withCredentials' in xhr) { 54 | return xhr 55 | } else if (win[xDomainRequest]) { 56 | return new XDomainRequest() 57 | } else { 58 | throw new Error('Browser does not support cross-origin requests') 59 | } 60 | } else if (win[xmlHttpRequest]) { 61 | return new XMLHttpRequest() 62 | } else { 63 | return new ActiveXObject('Microsoft.XMLHTTP') 64 | } 65 | } 66 | , globalSetupOptions = { 67 | dataFilter: function (data) { 68 | return data 69 | } 70 | } 71 | 72 | function succeed(r) { 73 | var protocol = protocolRe.exec(r.url); 74 | protocol = (protocol && protocol[1]) || window.location.protocol; 75 | return httpsRe.test(protocol) ? twoHundo.test(r.request.status) : !!r.request.responseText; 76 | } 77 | 78 | function handleReadyState(r, success, error) { 79 | return function () { 80 | // use _aborted to mitigate against IE err c00c023f 81 | // (can't read props on aborted request objects) 82 | if (r._aborted) return error(r.request) 83 | if (r._timedOut) return error(r.request, 'Request is aborted: timeout') 84 | if (r.request && r.request[readyState] == 4) { 85 | r.request.onreadystatechange = noop 86 | if (succeed(r)) success(r.request) 87 | else 88 | error(r.request) 89 | } 90 | } 91 | } 92 | 93 | function setHeaders(http, o) { 94 | var headers = o['headers'] || {} 95 | , h 96 | 97 | headers['Accept'] = headers['Accept'] 98 | || defaultHeaders['accept'][o['type']] 99 | || defaultHeaders['accept']['*'] 100 | 101 | var isAFormData = typeof FormData === 'function' && (o['data'] instanceof FormData); 102 | // breaks cross-origin requests with legacy browsers 103 | if (!o['crossOrigin'] && !headers[requestedWith]) headers[requestedWith] = defaultHeaders['requestedWith'] 104 | if (!headers[contentType] && !isAFormData) headers[contentType] = o['contentType'] || defaultHeaders['contentType'] 105 | for (h in headers) 106 | headers.hasOwnProperty(h) && 'setRequestHeader' in http && http.setRequestHeader(h, headers[h]) 107 | } 108 | 109 | function setCredentials(http, o) { 110 | if (typeof o['withCredentials'] !== 'undefined' && typeof http.withCredentials !== 'undefined') { 111 | http.withCredentials = !!o['withCredentials'] 112 | } 113 | } 114 | 115 | function generalCallback(data) { 116 | lastValue = data 117 | } 118 | 119 | function urlappend (url, s) { 120 | return url + (/\?/.test(url) ? '&' : '?') + s 121 | } 122 | 123 | function handleJsonp(o, fn, err, url) { 124 | var reqId = uniqid++ 125 | , cbkey = o['jsonpCallback'] || 'callback' // the 'callback' key 126 | , cbval = o['jsonpCallbackName'] || reqwest.getcallbackPrefix(reqId) 127 | , cbreg = new RegExp('((^|\\?|&)' + cbkey + ')=([^&]+)') 128 | , match = url.match(cbreg) 129 | , script = doc.createElement('script') 130 | , loaded = 0 131 | , isIE10 = navigator.userAgent.indexOf('MSIE 10.0') !== -1 132 | 133 | if (match) { 134 | if (match[3] === '?') { 135 | url = url.replace(cbreg, '$1=' + cbval) // wildcard callback func name 136 | } else { 137 | cbval = match[3] // provided callback func name 138 | } 139 | } else { 140 | url = urlappend(url, cbkey + '=' + cbval) // no callback details, add 'em 141 | } 142 | 143 | win[cbval] = generalCallback 144 | 145 | script.type = 'text/javascript' 146 | script.src = url 147 | script.async = true 148 | if (typeof script.onreadystatechange !== 'undefined' && !isIE10) { 149 | // need this for IE due to out-of-order onreadystatechange(), binding script 150 | // execution to an event listener gives us control over when the script 151 | // is executed. See http://jaubourg.net/2010/07/loading-script-as-onclick-handler-of.html 152 | script.htmlFor = script.id = '_reqwest_' + reqId 153 | } 154 | 155 | script.onload = script.onreadystatechange = function () { 156 | if ((script[readyState] && script[readyState] !== 'complete' && script[readyState] !== 'loaded') || loaded) { 157 | return false 158 | } 159 | script.onload = script.onreadystatechange = null 160 | script.onclick && script.onclick() 161 | // Call the user callback with the last value stored and clean up values and scripts. 162 | fn(lastValue) 163 | lastValue = undefined 164 | head.removeChild(script) 165 | loaded = 1 166 | } 167 | 168 | // Add the script to the DOM head 169 | head.appendChild(script) 170 | 171 | // Enable JSONP timeout 172 | return { 173 | abort: function () { 174 | script.onload = script.onreadystatechange = null 175 | err({}, 'Request is aborted: timeout', {}) 176 | lastValue = undefined 177 | head.removeChild(script) 178 | loaded = 1 179 | } 180 | } 181 | } 182 | 183 | function getRequest(fn, err) { 184 | var o = this.o 185 | , method = (o['method'] || 'GET').toUpperCase() 186 | , url = typeof o === 'string' ? o : o['url'] 187 | // convert non-string objects to query-string form unless o['processData'] is false 188 | , data = (o['processData'] !== false && o['data'] && typeof o['data'] !== 'string') 189 | ? reqwest.toQueryString(o['data']) 190 | : (o['data'] || null) 191 | , http 192 | , sendWait = false 193 | 194 | // if we're working on a GET request and we have data then we should append 195 | // query string to end of URL and not post data 196 | if ((o['type'] == 'jsonp' || method == 'GET') && data) { 197 | url = urlappend(url, data) 198 | data = null 199 | } 200 | 201 | if (o['type'] == 'jsonp') return handleJsonp(o, fn, err, url) 202 | 203 | // get the xhr from the factory if passed 204 | // if the factory returns null, fall-back to ours 205 | http = (o.xhr && o.xhr(o)) || xhr(o) 206 | 207 | http.open(method, url, o['async'] === false ? false : true) 208 | setHeaders(http, o) 209 | setCredentials(http, o) 210 | if (win[xDomainRequest] && http instanceof win[xDomainRequest]) { 211 | http.onload = fn 212 | http.onerror = err 213 | // NOTE: see 214 | // http://social.msdn.microsoft.com/Forums/en-US/iewebdevelopment/thread/30ef3add-767c-4436-b8a9-f1ca19b4812e 215 | http.onprogress = function() {} 216 | sendWait = true 217 | } else { 218 | http.onreadystatechange = handleReadyState(this, fn, err) 219 | } 220 | o['before'] && o['before'](http) 221 | if (sendWait) { 222 | setTimeout(function () { 223 | http.send(data) 224 | }, 200) 225 | } else { 226 | http.send(data) 227 | } 228 | return http 229 | } 230 | 231 | function Reqwest(o, fn) { 232 | this.o = o 233 | this.fn = fn 234 | 235 | init.apply(this, arguments) 236 | } 237 | 238 | function setType(header) { 239 | // json, javascript, text/plain, text/html, xml 240 | if (header.match('json')) return 'json' 241 | if (header.match('javascript')) return 'js' 242 | if (header.match('text')) return 'html' 243 | if (header.match('xml')) return 'xml' 244 | } 245 | 246 | function init(o, fn) { 247 | 248 | this.url = typeof o == 'string' ? o : o['url'] 249 | this.timeout = null 250 | 251 | // whether request has been fulfilled for purpose 252 | // of tracking the Promises 253 | this._fulfilled = false 254 | // success handlers 255 | this._successHandler = function(){} 256 | this._fulfillmentHandlers = [] 257 | // error handlers 258 | this._errorHandlers = [] 259 | // complete (both success and fail) handlers 260 | this._completeHandlers = [] 261 | this._erred = false 262 | this._responseArgs = {} 263 | 264 | var self = this 265 | 266 | fn = fn || function () {} 267 | 268 | if (o['timeout']) { 269 | this.timeout = setTimeout(function () { 270 | timedOut() 271 | }, o['timeout']) 272 | } 273 | 274 | if (o['success']) { 275 | this._successHandler = function () { 276 | o['success'].apply(o, arguments) 277 | } 278 | } 279 | 280 | if (o['error']) { 281 | this._errorHandlers.push(function () { 282 | o['error'].apply(o, arguments) 283 | }) 284 | } 285 | 286 | if (o['complete']) { 287 | this._completeHandlers.push(function () { 288 | o['complete'].apply(o, arguments) 289 | }) 290 | } 291 | 292 | function complete (resp) { 293 | o['timeout'] && clearTimeout(self.timeout) 294 | self.timeout = null 295 | while (self._completeHandlers.length > 0) { 296 | self._completeHandlers.shift()(resp) 297 | } 298 | } 299 | 300 | function success (resp) { 301 | var type = o['type'] || resp && setType(resp.getResponseHeader('Content-Type')) // resp can be undefined in IE 302 | resp = (type !== 'jsonp') ? self.request : resp 303 | // use global data filter on response text 304 | var filteredResponse = globalSetupOptions.dataFilter(resp.responseText, type) 305 | , r = filteredResponse 306 | try { 307 | resp.responseText = r 308 | } catch (e) { 309 | // can't assign this in IE<=8, just ignore 310 | } 311 | if (r) { 312 | switch (type) { 313 | case 'json': 314 | try { 315 | resp = win.JSON ? win.JSON.parse(r) : eval('(' + r + ')') 316 | } catch (err) { 317 | return error(resp, 'Could not parse JSON in response', err) 318 | } 319 | break 320 | case 'js': 321 | resp = eval(r) 322 | break 323 | case 'html': 324 | resp = r 325 | break 326 | case 'xml': 327 | resp = resp.responseXML 328 | && resp.responseXML.parseError // IE trololo 329 | && resp.responseXML.parseError.errorCode 330 | && resp.responseXML.parseError.reason 331 | ? null 332 | : resp.responseXML 333 | break 334 | } 335 | } 336 | 337 | self._responseArgs.resp = resp 338 | self._fulfilled = true 339 | fn(resp) 340 | self._successHandler(resp) 341 | while (self._fulfillmentHandlers.length > 0) { 342 | resp = self._fulfillmentHandlers.shift()(resp) 343 | } 344 | 345 | complete(resp) 346 | } 347 | 348 | function timedOut() { 349 | self._timedOut = true 350 | if(typeof self.request !== 'undefined' && typeof self.request.abort === 'function') { 351 | self.request.abort(); 352 | } 353 | } 354 | 355 | function error(resp, msg, t) { 356 | resp = self.request 357 | self._responseArgs.resp = resp 358 | self._responseArgs.msg = msg 359 | self._responseArgs.t = t 360 | self._erred = true 361 | while (self._errorHandlers.length > 0) { 362 | self._errorHandlers.shift()(resp, msg, t) 363 | } 364 | complete(resp) 365 | } 366 | 367 | this.request = getRequest.call(this, success, error) 368 | } 369 | 370 | Reqwest.prototype = { 371 | abort: function () { 372 | this._aborted = true 373 | if(typeof this.request !== 'undefined' && typeof this.request.abort === 'function') { 374 | this.request.abort(); 375 | } 376 | } 377 | 378 | , retry: function () { 379 | this._aborted=false; 380 | this._timedOut=false; 381 | init.call(this, this.o, this.fn) 382 | } 383 | 384 | /** 385 | * Small deviation from the Promises A CommonJs specification 386 | * http://wiki.commonjs.org/wiki/Promises/A 387 | */ 388 | 389 | /** 390 | * `then` will execute upon successful requests 391 | */ 392 | , then: function (success, fail) { 393 | success = success || function () {} 394 | fail = fail || function () {} 395 | if (this._fulfilled) { 396 | this._responseArgs.resp = success(this._responseArgs.resp) 397 | } else if (this._erred) { 398 | fail(this._responseArgs.resp, this._responseArgs.msg, this._responseArgs.t) 399 | } else { 400 | this._fulfillmentHandlers.push(success) 401 | this._errorHandlers.push(fail) 402 | } 403 | return this 404 | } 405 | 406 | /** 407 | * `always` will execute whether the request succeeds or fails 408 | */ 409 | , always: function (fn) { 410 | if (this._fulfilled || this._erred) { 411 | fn(this._responseArgs.resp) 412 | } else { 413 | this._completeHandlers.push(fn) 414 | } 415 | return this 416 | } 417 | 418 | /** 419 | * `fail` will execute when the request fails 420 | */ 421 | , fail: function (fn) { 422 | if (this._erred) { 423 | fn(this._responseArgs.resp, this._responseArgs.msg, this._responseArgs.t) 424 | } else { 425 | this._errorHandlers.push(fn) 426 | } 427 | return this 428 | } 429 | , 'catch': function (fn) { 430 | return this.fail(fn) 431 | } 432 | } 433 | 434 | function reqwest(o, fn) { 435 | return new Reqwest(o, fn) 436 | } 437 | 438 | // normalize newline variants according to spec -> CRLF 439 | function normalize(s) { 440 | return s ? s.replace(/\r?\n/g, '\r\n') : '' 441 | } 442 | 443 | function serial(el, cb) { 444 | var n = el.name 445 | , t = el.tagName.toLowerCase() 446 | , optCb = function (o) { 447 | // IE gives value="" even where there is no value attribute 448 | // 'specified' ref: http://www.w3.org/TR/DOM-Level-3-Core/core.html#ID-862529273 449 | if (o && !o['disabled']) 450 | cb(n, normalize(o['attributes']['value'] && o['attributes']['value']['specified'] ? o['value'] : o['text'])) 451 | } 452 | , ch, ra, val, i 453 | 454 | // don't serialize elements that are disabled or without a name 455 | if (el.disabled || !n) return 456 | 457 | switch (t) { 458 | case 'input': 459 | if (!/reset|button|image|file/i.test(el.type)) { 460 | ch = /checkbox/i.test(el.type) 461 | ra = /radio/i.test(el.type) 462 | val = el.value 463 | // WebKit gives us "" instead of "on" if a checkbox has no value, so correct it here 464 | ;(!(ch || ra) || el.checked) && cb(n, normalize(ch && val === '' ? 'on' : val)) 465 | } 466 | break 467 | case 'textarea': 468 | cb(n, normalize(el.value)) 469 | break 470 | case 'select': 471 | if (el.type.toLowerCase() === 'select-one') { 472 | optCb(el.selectedIndex >= 0 ? el.options[el.selectedIndex] : null) 473 | } else { 474 | for (i = 0; el.length && i < el.length; i++) { 475 | el.options[i].selected && optCb(el.options[i]) 476 | } 477 | } 478 | break 479 | } 480 | } 481 | 482 | // collect up all form elements found from the passed argument elements all 483 | // the way down to child elements; pass a '' or form fields. 484 | // called with 'this'=callback to use for serial() on each element 485 | function eachFormElement() { 486 | var cb = this 487 | , e, i 488 | , serializeSubtags = function (e, tags) { 489 | var i, j, fa 490 | for (i = 0; i < tags.length; i++) { 491 | fa = e[byTag](tags[i]) 492 | for (j = 0; j < fa.length; j++) serial(fa[j], cb) 493 | } 494 | } 495 | 496 | for (i = 0; i < arguments.length; i++) { 497 | e = arguments[i] 498 | if (/input|select|textarea/i.test(e.tagName)) serial(e, cb) 499 | serializeSubtags(e, [ 'input', 'select', 'textarea' ]) 500 | } 501 | } 502 | 503 | // standard query string style serialization 504 | function serializeQueryString() { 505 | return reqwest.toQueryString(reqwest.serializeArray.apply(null, arguments)) 506 | } 507 | 508 | // { 'name': 'value', ... } style serialization 509 | function serializeHash() { 510 | var hash = {} 511 | eachFormElement.apply(function (name, value) { 512 | if (name in hash) { 513 | hash[name] && !isArray(hash[name]) && (hash[name] = [hash[name]]) 514 | hash[name].push(value) 515 | } else hash[name] = value 516 | }, arguments) 517 | return hash 518 | } 519 | 520 | // [ { name: 'name', value: 'value' }, ... ] style serialization 521 | reqwest.serializeArray = function () { 522 | var arr = [] 523 | eachFormElement.apply(function (name, value) { 524 | arr.push({name: name, value: value}) 525 | }, arguments) 526 | return arr 527 | } 528 | 529 | reqwest.serialize = function () { 530 | if (arguments.length === 0) return '' 531 | var opt, fn 532 | , args = Array.prototype.slice.call(arguments, 0) 533 | 534 | opt = args.pop() 535 | opt && opt.nodeType && args.push(opt) && (opt = null) 536 | opt && (opt = opt.type) 537 | 538 | if (opt == 'map') fn = serializeHash 539 | else if (opt == 'array') fn = reqwest.serializeArray 540 | else fn = serializeQueryString 541 | 542 | return fn.apply(null, args) 543 | } 544 | 545 | reqwest.toQueryString = function (o, trad) { 546 | var prefix, i 547 | , traditional = trad || false 548 | , s = [] 549 | , enc = encodeURIComponent 550 | , add = function (key, value) { 551 | // If value is a function, invoke it and return its value 552 | value = ('function' === typeof value) ? value() : (value == null ? '' : value) 553 | s[s.length] = enc(key) + '=' + enc(value) 554 | } 555 | // If an array was passed in, assume that it is an array of form elements. 556 | if (isArray(o)) { 557 | for (i = 0; o && i < o.length; i++) add(o[i]['name'], o[i]['value']) 558 | } else { 559 | // If traditional, encode the "old" way (the way 1.3.2 or older 560 | // did it), otherwise encode params recursively. 561 | for (prefix in o) { 562 | if (o.hasOwnProperty(prefix)) buildParams(prefix, o[prefix], traditional, add) 563 | } 564 | } 565 | 566 | // spaces should be + according to spec 567 | return s.join('&').replace(/%20/g, '+') 568 | } 569 | 570 | function buildParams(prefix, obj, traditional, add) { 571 | var name, i, v 572 | , rbracket = /\[\]$/ 573 | 574 | if (isArray(obj)) { 575 | // Serialize array item. 576 | for (i = 0; obj && i < obj.length; i++) { 577 | v = obj[i] 578 | if (traditional || rbracket.test(prefix)) { 579 | // Treat each array item as a scalar. 580 | add(prefix, v) 581 | } else { 582 | buildParams(prefix + '[' + (typeof v === 'object' ? i : '') + ']', v, traditional, add) 583 | } 584 | } 585 | } else if (obj && obj.toString() === '[object Object]') { 586 | // Serialize object item. 587 | for (name in obj) { 588 | buildParams(prefix + '[' + name + ']', obj[name], traditional, add) 589 | } 590 | 591 | } else { 592 | // Serialize scalar item. 593 | add(prefix, obj) 594 | } 595 | } 596 | 597 | reqwest.getcallbackPrefix = function () { 598 | return callbackPrefix 599 | } 600 | 601 | // jQuery and Zepto compatibility, differences can be remapped here so you can call 602 | // .ajax.compat(options, callback) 603 | reqwest.compat = function (o, fn) { 604 | if (o) { 605 | o['type'] && (o['method'] = o['type']) && delete o['type'] 606 | o['dataType'] && (o['type'] = o['dataType']) 607 | o['jsonpCallback'] && (o['jsonpCallbackName'] = o['jsonpCallback']) && delete o['jsonpCallback'] 608 | o['jsonp'] && (o['jsonpCallback'] = o['jsonp']) 609 | } 610 | return new Reqwest(o, fn) 611 | } 612 | 613 | reqwest.ajaxSetup = function (options) { 614 | options = options || {} 615 | for (var k in options) { 616 | globalSetupOptions[k] = options[k] 617 | } 618 | } 619 | 620 | return reqwest 621 | }); 622 | -------------------------------------------------------------------------------- /lib/store2.js: -------------------------------------------------------------------------------- 1 | /*! store2 - v2.3.0 - 2015-05-22 2 | * Copyright (c) 2015 Nathan Bubna; Licensed MIT, GPL */ 3 | ;(function(window, define) { 4 | var _ = { 5 | version: "2.3.0", 6 | areas: {}, 7 | apis: {}, 8 | 9 | // utilities 10 | inherit: function(api, o) { 11 | for (var p in api) { 12 | if (!o.hasOwnProperty(p)) { 13 | o[p] = api[p]; 14 | } 15 | } 16 | return o; 17 | }, 18 | stringify: function(d) { 19 | return d === undefined || typeof d === "function" ? d+'' : JSON.stringify(d); 20 | }, 21 | parse: function(s) { 22 | // if it doesn't parse, return as is 23 | try{ return JSON.parse(s); }catch(e){ return s; } 24 | }, 25 | 26 | // extension hooks 27 | fn: function(name, fn) { 28 | _.storeAPI[name] = fn; 29 | for (var api in _.apis) { 30 | _.apis[api][name] = fn; 31 | } 32 | }, 33 | get: function(area, key){ return area.getItem(key); }, 34 | set: function(area, key, string){ area.setItem(key, string); }, 35 | remove: function(area, key){ area.removeItem(key); }, 36 | key: function(area, i){ return area.key(i); }, 37 | length: function(area){ return area.length; }, 38 | clear: function(area){ area.clear(); }, 39 | 40 | // core functions 41 | Store: function(id, area, namespace) { 42 | var store = _.inherit(_.storeAPI, function(key, data, overwrite) { 43 | if (arguments.length === 0){ return store.getAll(); } 44 | if (data !== undefined){ return store.set(key, data, overwrite); } 45 | if (typeof key === "string"){ return store.get(key); } 46 | if (!key){ return store.clear(); } 47 | return store.setAll(key, data);// overwrite=data, data=key 48 | }); 49 | store._id = id; 50 | try { 51 | var testKey = '_safariPrivate_'; 52 | area.setItem(testKey, 'sucks'); 53 | store._area = area; 54 | area.removeItem(testKey); 55 | } catch (e) {} 56 | if (!store._area) { 57 | store._area = _.inherit(_.storageAPI, { items: {}, name: 'fake' }); 58 | } 59 | store._ns = namespace || ''; 60 | if (!_.areas[id]) { 61 | _.areas[id] = store._area; 62 | } 63 | if (!_.apis[store._ns+store._id]) { 64 | _.apis[store._ns+store._id] = store; 65 | } 66 | return store; 67 | }, 68 | storeAPI: { 69 | // admin functions 70 | area: function(id, area) { 71 | var store = this[id]; 72 | if (!store || !store.area) { 73 | store = _.Store(id, area, this._ns);//new area-specific api in this namespace 74 | if (!this[id]){ this[id] = store; } 75 | } 76 | return store; 77 | }, 78 | namespace: function(namespace, noSession) { 79 | if (!namespace){ 80 | return this._ns ? this._ns.substring(0,this._ns.length-1) : ''; 81 | } 82 | var ns = namespace, store = this[ns]; 83 | if (!store || !store.namespace) { 84 | store = _.Store(this._id, this._area, this._ns+ns+'.');//new namespaced api 85 | if (!this[ns]){ this[ns] = store; } 86 | if (!noSession){ store.area('session', _.areas.session); } 87 | } 88 | return store; 89 | }, 90 | isFake: function(){ return this._area.name === 'fake'; }, 91 | toString: function() { 92 | return 'store'+(this._ns?'.'+this.namespace():'')+'['+this._id+']'; 93 | }, 94 | 95 | // storage functions 96 | has: function(key) { 97 | if (this._area.has) { 98 | return this._area.has(this._in(key));//extension hook 99 | } 100 | return !!(this._in(key) in this._area); 101 | }, 102 | size: function(){ return this.keys().length; }, 103 | each: function(fn, and) { 104 | for (var i=0, m=_.length(this._area); i _.length(this._area)) { m--; i--; }// in case of removeItem 112 | } 113 | return and || this; 114 | }, 115 | keys: function() { 116 | return this.each(function(k, list){ list.push(k); }, []); 117 | }, 118 | get: function(key, alt) { 119 | var s = _.get(this._area, this._in(key)); 120 | return s !== null ? _.parse(s) : alt || s;// support alt for easy default mgmt 121 | }, 122 | getAll: function() { 123 | return this.each(function(k, all){ all[k] = this.get(k); }, {}); 124 | }, 125 | set: function(key, data, overwrite) { 126 | var d = this.get(key); 127 | if (d != null && overwrite === false) { 128 | return data; 129 | } 130 | return _.set(this._area, this._in(key), _.stringify(data), overwrite) || d; 131 | }, 132 | setAll: function(data, overwrite) { 133 | var changed, val; 134 | for (var key in data) { 135 | val = data[key]; 136 | if (this.set(key, val, overwrite) !== val) { 137 | changed = true; 138 | } 139 | } 140 | return changed; 141 | }, 142 | remove: function(key) { 143 | var d = this.get(key); 144 | _.remove(this._area, this._in(key)); 145 | return d; 146 | }, 147 | clear: function() { 148 | if (!this._ns) { 149 | _.clear(this._area); 150 | } else { 151 | this.each(function(k){ _.remove(this._area, this._in(k)); }, 1); 152 | } 153 | return this; 154 | }, 155 | clearAll: function() { 156 | var area = this._area; 157 | for (var id in _.areas) { 158 | if (_.areas.hasOwnProperty(id)) { 159 | this._area = _.areas[id]; 160 | this.clear(); 161 | } 162 | } 163 | this._area = area; 164 | return this; 165 | }, 166 | 167 | // internal use functions 168 | _in: function(k) { 169 | if (typeof k !== "string"){ k = _.stringify(k); } 170 | return this._ns ? this._ns + k : k; 171 | }, 172 | _out: function(k) { 173 | return this._ns ? 174 | k && k.indexOf(this._ns) === 0 ? 175 | k.substring(this._ns.length) : 176 | undefined : // so each() knows to skip it 177 | k; 178 | } 179 | },// end _.storeAPI 180 | storageAPI: { 181 | length: 0, 182 | has: function(k){ return this.items.hasOwnProperty(k); }, 183 | key: function(i) { 184 | var c = 0; 185 | for (var k in this.items){ 186 | if (this.has(k) && i === c++) { 187 | return k; 188 | } 189 | } 190 | }, 191 | setItem: function(k, v) { 192 | if (!this.has(k)) { 193 | this.length++; 194 | } 195 | this.items[k] = v; 196 | }, 197 | removeItem: function(k) { 198 | if (this.has(k)) { 199 | delete this.items[k]; 200 | this.length--; 201 | } 202 | }, 203 | getItem: function(k){ return this.has(k) ? this.items[k] : null; }, 204 | clear: function(){ for (var k in this.list){ this.removeItem(k); } }, 205 | toString: function(){ return this.length+' items in '+this.name+'Storage'; } 206 | }// end _.storageAPI 207 | }; 208 | 209 | // setup the primary store fn 210 | if (window.store){ _.conflict = window.store; } 211 | var store = 212 | // safely set this up (throws error in IE10/32bit mode for local files) 213 | _.Store("local", (function(){try{ return localStorage; }catch(e){}})()); 214 | store.local = store;// for completeness 215 | store._ = _;// for extenders and debuggers... 216 | // safely setup store.session (throws exception in FF for file:/// urls) 217 | store.area("session", (function(){try{ return sessionStorage; }catch(e){}})()); 218 | 219 | //Expose store to the global object 220 | window.store = store; 221 | 222 | if (typeof define === 'function' && define.amd !== undefined) { 223 | define(function () { 224 | return store; 225 | }); 226 | } else if (typeof module !== 'undefined' && module.exports) { 227 | module.exports = store; 228 | } 229 | 230 | })(this, this.define); 231 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gumshoe/Gumshoe/a645683d4c2d7dc4f42ef323302a9bbe38f8553c/logo.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Gumshoe", 3 | "version": "0.8.1", 4 | "description": "An analytics and event tracking sleuth.", 5 | "keywords": [ 6 | "tracking", 7 | "events", 8 | "google", 9 | "ga", 10 | "analytics", 11 | "gumshoe", 12 | "sleuth" 13 | ], 14 | "author": "Gilt Tech ", 15 | "maintainers": [ 16 | { 17 | "name": "Andrew Powell", 18 | "email": "andrew@shellscape.org" 19 | } 20 | ], 21 | "main": "./dist/gumshoe.js", 22 | "bugs": { 23 | "url": "http://github.com/gilt/gumshoe/issues" 24 | }, 25 | "homepage": "https://github.com/gilt/gumshoe", 26 | "licenses": [ 27 | { 28 | "type": "MIT", 29 | "url": "http://github.com/gilt/gumshoe/blob/master/LICENSE" 30 | } 31 | ], 32 | "repository": { 33 | "type": "git", 34 | "url": "http://github.com/gilt/gumshoe.git" 35 | }, 36 | "scripts": { 37 | "test": "gulp test" 38 | }, 39 | "dependencies": {}, 40 | "devDependencies": { 41 | "chai": "^3.3.0", 42 | "del": "^2.0.2", 43 | "gulp": "^3.8.10", 44 | "gulp-add-src": "^0.2.0", 45 | "gulp-concat": "^2.4.3", 46 | "gulp-file-insert": "^1.0.2", 47 | "gulp-jshint": "^2.0.1", 48 | "gulp-mocha": "^4.2.0", 49 | "gulp-mocha-phantomjs": "^0.12.1", 50 | "gulp-rename": "^1.2.0", 51 | "gulp-replace": "^0.5.1", 52 | "gulp-uglify": "^2.0.0", 53 | "jshint": "^2.9.3", 54 | "jshint-stylish": "^2.0.1" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/gumshoe.js: -------------------------------------------------------------------------------- 1 | /* global performance */ 2 | (function (root) { 3 | 4 | 'use strict'; 5 | 6 | // we need reqwest and store2 (and any other future deps) 7 | // to be solely within our context, so as they don't leak and conflict 8 | // with other versions of the same libs sites may be loading. 9 | // so we'll provide our own context. 10 | // root._gumshoe is only available in specs 11 | var context = root._gumshoe || {}, 12 | queryString, 13 | store, 14 | /*jshint -W024 */ 15 | undefined; 16 | 17 | // call contextSetup with 'context' as 'this' so all libs attach 18 | // to our context variable. 19 | (function contextSetup () { 20 | // query-string.js 21 | 22 | // reqwest.js 23 | 24 | // store2.js 25 | 26 | }).call(context); 27 | 28 | queryString = context.queryString; 29 | store = context.store; 30 | 31 | function extend (obj) { 32 | if (!isObject(obj)) { 33 | return obj; 34 | } 35 | var source, prop; 36 | for (var i = 1, length = arguments.length; i < length; i++) { 37 | source = arguments[i]; 38 | for (prop in source) { 39 | obj[prop] = source[prop]; 40 | } 41 | } 42 | return obj; 43 | } 44 | 45 | function isArray (obj) { 46 | return '[object Array]' === Object.prototype.toString.call(obj); 47 | } 48 | 49 | function isFunction (obj) { 50 | return ('' + typeof obj) === 'function'; 51 | } 52 | 53 | function isObject (obj) { 54 | var type = typeof obj; 55 | return type === 'function' || type === 'object' && !!obj; 56 | } 57 | 58 | function isString (value) { 59 | return typeof value == 'string' || (value && typeof value == 'object' && 60 | Object.prototype.toString.call(value) == '[object String]') || false; 61 | } 62 | 63 | function uuidv4 (){ 64 | var d = performance.now(); 65 | var uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { 66 | var r = (d + Math.random()*16)%16 | 0; 67 | d = Math.floor(d/16); 68 | return (c=='x' ? r : (r&0x3|0x8)).toString(16); 69 | }); 70 | return uuid; 71 | } 72 | 73 | var defaults = { 74 | transport: '', 75 | queueTimeout: 100 76 | }, 77 | localStore = store.namespace('gumshoe'), 78 | storage = store.namespace('gumshoe').session, 79 | queue = storage('queue') || [], 80 | transports = {}; 81 | 82 | if (!isArray(queue)) { 83 | queue = []; 84 | } 85 | 86 | function gumshoe (options) { 87 | var clientUuid = localStore('clientUuid'); 88 | 89 | options = extend({}, defaults, options); 90 | 91 | // always ensure options.transport is an array. 92 | if (isString(options.transport)) { 93 | options.transport = [options.transport]; 94 | } 95 | else if (!isArray(options.transport)) { 96 | throw 'Gumeshoe: Transport property must be a [String] or [Array].'; 97 | } 98 | 99 | // store a client id to identify a client long-term. Google Analytics uses 100 | // the value, combined with other factors, to determine unique users. we 101 | // duplicate the same kind of value to assist GA. 102 | if (!clientUuid) { 103 | clientUuid = uuidv4(); 104 | localStore({ clientUuid: clientUuid }); 105 | } 106 | 107 | options.clientUuid = clientUuid; 108 | 109 | session(options.sessionFn); 110 | 111 | gumshoe.options = options; 112 | } 113 | 114 | function each (obj, iterator, context) { 115 | if (obj === null) { 116 | return; 117 | } 118 | 119 | if (Array.prototype.forEach && obj.forEach === Array.prototype.forEach) { 120 | obj.forEach(iterator, context); 121 | } 122 | else if (obj.length === +obj.length) { 123 | for (var i = 0, l = obj.length; i < l; i++) { 124 | if (iterator.call(context, obj[i], i, obj) === {}) { 125 | return; 126 | } 127 | } 128 | } 129 | else { 130 | for (var key in obj) { 131 | if (obj.hasOwnProperty(key)) { 132 | if (iterator.call(context, obj[key], key, obj) === {}) { 133 | return; 134 | } 135 | } 136 | } 137 | } 138 | } 139 | 140 | function map (obj, iterator, context) { 141 | var results = []; 142 | 143 | if (!obj) { 144 | return results; 145 | } 146 | 147 | if (Array.prototype.map && obj.map === Array.prototype.map) { 148 | return obj.map(iterator, context); 149 | } 150 | 151 | each(obj, function(value, index, list) { 152 | results[results.length] = iterator.call(context, value, index, list); 153 | }); 154 | 155 | return results; 156 | } 157 | 158 | function collectPlugins () { 159 | var result, 160 | plugins = navigator.plugins || []; 161 | 162 | result = map(plugins, function (plugin) { 163 | var mimeTypes = map(plugin, function (mimeType) { 164 | var type = mimeType.type; 165 | 166 | if (mimeType.suffixes) { 167 | type += '~' + mimeType.suffixes; 168 | } 169 | 170 | return type; 171 | }); 172 | 173 | return { 174 | description: plugin.description, 175 | filename: plugin.filename, 176 | mimeTypes: mimeTypes, 177 | name: plugin.name 178 | }; 179 | }); 180 | 181 | return result; 182 | } 183 | 184 | function collect () { 185 | 186 | function getViewport() { 187 | var e = window, a = 'inner'; 188 | if (!('innerWidth' in window )) { 189 | a = 'client'; 190 | e = document.documentElement || document.body; 191 | } 192 | return { width : e[ a+'Width' ] , height : e[ a+'Height' ] }; 193 | } 194 | 195 | var 196 | viewport = getViewport(), 197 | 198 | query = queryString.parse(location.search), 199 | 200 | result = { 201 | // utmcs Character set (e.g. ISO-8859-1) 202 | characterSet: document.characterSet || document.charset || document.inputEncoding || 'Unknown', 203 | 204 | // utmsc Screen colour depth (e.g. 24-bit) 205 | colorDepth: screen.colorDepth + '', 206 | 207 | cookie: document.cookie, 208 | 209 | // gclid Gclid is a globally unique tracking parameter (Google Click Identifier) 210 | googleClickId: query.gclid || '', 211 | 212 | hash: window.location.hash, 213 | host: window.location.host, 214 | 215 | // utmhn Hostname 216 | hostName: window.location.hostname, 217 | 218 | // utmip IP address 219 | ipAddress: '', 220 | 221 | // utmje Java enabled? 222 | javaEnabled: navigator.javaEnabled ? navigator.javaEnabled() : false, 223 | 224 | // utmul Language code (e.g. en-us) 225 | language: document.documentElement ? document.documentElement.lang : window.navigator.language || 'Unknown', 226 | 227 | // login key: ?lk= 228 | loginKey: query.lk || '', 229 | 230 | // IE9 doesn't support this 231 | origin: window.location.origin || '', 232 | 233 | // utmp Page path 234 | path: window.location.pathname, 235 | platform: window.navigator.platform, 236 | plugins: collectPlugins(), 237 | port: window.location.port || 80, 238 | // promotional key: pkey 239 | promotionKey: query.pkey || '', 240 | protocol: window.location.protocol, 241 | 242 | queryString: window.location.search, 243 | 244 | // utmr Full referral URL 245 | referer: document.referrer, 246 | 247 | screenAvailHeight: screen.availHeight, 248 | screenAvailWidth: screen.availWidth, 249 | screenHeight: screen.height, 250 | screenOrientationAngle: '', 251 | screenOrientationType: '', 252 | screenPixelDepth: screen.pixelDepth + '', 253 | // utmsr Screen resolution 254 | screenResolution: screen.width + 'x' + screen.height, 255 | screenWidth: screen.width, 256 | 257 | // utmdt Page title 258 | title: document.title, 259 | 260 | url: window.location.href, 261 | userAgent: window.navigator.userAgent, 262 | utmCampaign: query.utm_campaign || '', 263 | utmContent: query.utm_content || '', 264 | utmMedium: query.utm_medium || '', 265 | utmSource: query.utm_source || '', 266 | utmTerm: query.utm_term || '', 267 | 268 | // utmvp Viewport resolution 269 | viewportHeight: viewport.height, 270 | viewportResolution: viewport.width + 'x' + viewport.height, 271 | viewportWidth: viewport.width 272 | }, 273 | 274 | intFields = [ 275 | 'port', 'screenAvailHeight', 'screenAvailWidth', 'screenHeight', 276 | 'screenOrientationAngle', 'screenWidth', 'viewportHeight', 'viewportWidth' 277 | ], 278 | prop, 279 | value; 280 | 281 | // some browsers don't support navigator.javaEnabled(), it's always undefined. 282 | if (result.javaEnabled === undefined) { 283 | result.javaEnabled = false; 284 | } 285 | 286 | // IE 8, 9 don't support this. Yay. 287 | if (screen.orientation) { 288 | result.screenOrientationAngle = parseInt(screen.orientation.angle ? screen.orientation.angle : '0'); 289 | result.screenOrientationType = screen.orientation.type ? screen.orientation.type : ''; 290 | 291 | if (isNaN(result.screenOrientationAngle)) { 292 | result.screenOrientationAngle = 0; 293 | } 294 | } 295 | 296 | // assert that these values are ints 297 | for (var i = 0; i < intFields.length; i++) { 298 | prop = intFields[i]; 299 | value = parseInt(result[prop]); 300 | 301 | if (isNaN(value)) { 302 | value = 0; 303 | } 304 | 305 | result[prop] = value; 306 | } 307 | 308 | return result; 309 | } 310 | 311 | /** 312 | * @private 313 | * @method session 314 | * 315 | * @note 316 | * Gumshoe Session Rules 317 | * 318 | * Generate a new Session ID if any of the following criteria are met: 319 | * 320 | * 1. User opens new tab or window (browser default behavior) 321 | * 2. User has been inactive longer than 30 minutes 322 | * 3. User has visited withinin the same session, but a UTM 323 | * query string parameter has changed. 324 | */ 325 | function session (fn) { 326 | 327 | // returns a simple object containing utm parameters 328 | function getUtm () { 329 | return { 330 | campaign: query.utm_campaign || '', 331 | medium: query.utm_medium || '', 332 | source: query.utm_source || '', 333 | utmTerm: query.utm_term || '' 334 | }; 335 | } 336 | 337 | var now = (new Date()).getTime(), 338 | query = queryString.parse(location.search), 339 | lastUtm = storage('utm') || getUtm(), 340 | utm = getUtm(), 341 | timestamp, 342 | difference; 343 | 344 | // save the current state of the utm parameters 345 | storage('utm', utm); 346 | 347 | // set a session based uuid 348 | if (!storage('uuid')) { 349 | storage('uuid', uuidv4()); 350 | storage('timestamp', now); 351 | } 352 | else { 353 | timestamp = storage('timestamp'); 354 | difference = now - timestamp; 355 | 356 | if (fn) { 357 | /* jshint validthis: true */ 358 | if (fn.call(this, timestamp, difference, query)) { 359 | storage('uuid', uuidv4()); 360 | } 361 | } 362 | else if (JSON.stringify(lastUtm) !== JSON.stringify(utm) || difference > (1000 * 60 * 30)) { 363 | storage('uuid', uuidv4()); 364 | } 365 | } 366 | } 367 | 368 | function send (eventName, eventData) { 369 | var pageData = collect(), 370 | baseData = { 371 | clientUuid: gumshoe.options.clientUuid, 372 | eventName: eventName, 373 | eventData: eventData || {}, 374 | gumshoe: '{package_version}', 375 | pageData: pageData, 376 | sessionUuid: storage('uuid'), 377 | timestamp: (new Date()).getTime(), 378 | timezoneOffset: (new Date()).getTimezoneOffset(), 379 | uuid: uuidv4() 380 | }, 381 | transportFound = false; 382 | 383 | // since we're dealing with timeouts now, we need to reassert the 384 | // session ID for each event sent. 385 | session(gumshoe.options.sessionFn); 386 | 387 | for(var i = 0; i < gumshoe.options.transport.length; i++) { 388 | var transportName = gumshoe.options.transport[i], 389 | transport, 390 | data; 391 | 392 | if (transportName && transports[transportName]) { 393 | transportFound = true; 394 | transport = transports[transportName]; 395 | 396 | // allow each transport to extend the data with more information 397 | // or transform it how they'd like. transports cannot however, 398 | // modify eventData sent from the client. 399 | data = transport.map ? transport.map(baseData) : baseData; 400 | 401 | // extend our data with whatever came back from the transport 402 | data = extend(baseData, data); 403 | 404 | // TODO: remove this. gumshoe shouldn't care what format this is in 405 | if (!isString(data.eventData)) { 406 | data.eventData = JSON.stringify(data.eventData); 407 | } 408 | 409 | // TODO: remove this. gumshoe shouldn't care what format this is in 410 | if (!isString(data.pageData.plugins)) { 411 | data.pageData.plugins = JSON.stringify(data.pageData.plugins); 412 | } 413 | 414 | // TODO: remove this. temporary bugfix for apps 415 | if (!data.pageData.ipAddress) { 416 | data.pageData.ipAddress = ''; 417 | } 418 | 419 | pushEvent(eventName, transportName, data); 420 | } 421 | else { 422 | throw 'Gumshoe: The transport name: ' + transportName + ', doesn\'t map to a valid transport.'; 423 | } 424 | } 425 | 426 | if (!transportFound) { 427 | throw 'Gumshoe: No valid transports were found.'; 428 | } 429 | } 430 | 431 | function nextEvent () { 432 | 433 | if (!queue.length) { 434 | return; 435 | } 436 | 437 | // granb the next event from the queue and remove it. 438 | var nevent = queue.shift(), 439 | transport = transports[nevent.transportName]; 440 | 441 | transport.send(nevent.data, function (err, result) { 442 | // we care if an error was thrown, created, or captured 443 | // if there is an error, add the item back into the queue 444 | if (err) { 445 | queue.push(nevent); 446 | 447 | console.warn('Gumshoe: Retrying. Error received from transport: ' + nevent.transportName + ', for event: ' + nevent.eventName); 448 | } 449 | 450 | // put our newly modified queue in session storage 451 | // we're doing this after we send the event to mitigate data loss 452 | // in the event if the request doesn't complete before the page changes 453 | storage('queue', queue); 454 | }); 455 | 456 | if (transport.send.length === 1) { 457 | // TEMP HACK: 0.1.x of tracking_api.gumshoe didn't accept a callback 458 | // parameter, because we didn't care about the failure of an event. 459 | // we need to support 0.1.x in the same old way while 0.2.x is being 460 | // rolled out. 461 | storage('queue', queue); 462 | } 463 | 464 | setTimeout(nextEvent, gumshoe.options.queueTimeout); 465 | } 466 | 467 | function pushEvent (eventName, transportName, data) { 468 | 469 | var transport; 470 | 471 | // if we're dealing with a fake storage object 472 | // (eg. sessionStorage isn't available) then don't 473 | // even bother queueing the data. 474 | if (storage.isFake()) { 475 | transport = transports[transportName]; 476 | transport.send(data); 477 | 478 | return; 479 | } 480 | 481 | // add the event data to the queue 482 | queue.push({ 483 | eventName: eventName, 484 | transportName: transportName, 485 | data: data 486 | }); 487 | 488 | // put our newly modified queue in session storage 489 | storage('queue', queue); 490 | 491 | setTimeout(nextEvent, gumshoe.options.queueTimeout); 492 | } 493 | 494 | function transport (tp) { 495 | if (!tp.name) { 496 | throw 'Gumshoe: Transport [Object] must have a name defined.'; 497 | } 498 | 499 | transports[tp.name] = tp; 500 | } 501 | 502 | // setup some static properties 503 | gumshoe.version = '{package_version}'; 504 | gumshoe.options = {}; 505 | 506 | // setup some static methods 507 | gumshoe.extend = extend; 508 | gumshoe.reqwest = context.reqwest; 509 | gumshoe.send = send; 510 | gumshoe.transport = transport; 511 | gumshoe.uuid = uuidv4; 512 | 513 | // setup some internal stuff for access 514 | gumshoe._ = { 515 | collect: collect, 516 | localStorage: localStore, 517 | queryString: queryString, 518 | queue: queue, 519 | storage: storage, 520 | transports: transports 521 | }; 522 | 523 | if (root.gumshoe) { 524 | 525 | if (root.gumshoe.ready) { 526 | root.gumshoe.ready = gumshoe.ready = root.gumshoe.ready; 527 | root.gumshoe = gumshoe; 528 | 529 | if (!isFunction(root.gumshoe.ready.resolve)) { 530 | throw 'Gumshoe: gumshoe.ready was predefined, but is not a Promise/A deferred.'; 531 | } 532 | 533 | root.gumshoe.ready.resolve(); 534 | } 535 | } 536 | else { 537 | root.gumshoe = gumshoe; 538 | } 539 | 540 | })(this); 541 | -------------------------------------------------------------------------------- /test/fixtures.js: -------------------------------------------------------------------------------- 1 | (function (root) { 2 | 3 | var gumshoe = root.gumshoe; 4 | 5 | gumshoe.transport({ 6 | 7 | name: 'spec-transport-legacy', 8 | 9 | send: function (data) { 10 | }, 11 | 12 | map: function (data) { 13 | return { 14 | newProp: 1, 15 | ipAddress: '192.168.1.1' 16 | }; 17 | } 18 | 19 | }); 20 | 21 | gumshoe.transport({ 22 | 23 | name: 'spec-transport-error', 24 | 25 | send: function (data, fn) { 26 | fn.call(this, {}, null); 27 | }, 28 | 29 | map: function (data) { 30 | return { 31 | newProp: 1, 32 | ipAddress: '192.168.1.1' 33 | }; 34 | } 35 | 36 | }); 37 | 38 | gumshoe.transport({ 39 | 40 | name: 'spec-transport', 41 | 42 | send: function (data, fn) { 43 | fn.call(this, null, {}); 44 | }, 45 | 46 | map: function (data) { 47 | return { 48 | newProp: 1, 49 | ipAddress: '192.168.1.1' 50 | }; 51 | } 52 | 53 | }); 54 | 55 | })(this); 56 | -------------------------------------------------------------------------------- /test/promise/rsvp-runner.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Test: Gumshoe Promise - rsvp.js 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 29 | 30 | 31 | 32 | 33 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /test/promise/rsvp-specs.js: -------------------------------------------------------------------------------- 1 | 2 | describe('Gumshoe Promise - rsvp.js', function() { 3 | 4 | this.timeout(5000); 5 | 6 | var ready = false; 7 | 8 | it('should find RSVP', function () { 9 | expect(RSVP).to.exist; 10 | }); 11 | 12 | it('should not find Gumshoe', function () { 13 | expect(window.gumshoe).to.not.exist; 14 | }); 15 | 16 | it('should setup the Promise for Gumshoe', function () { 17 | 18 | window.gumshoe = { 19 | ready: RSVP.defer() 20 | }; 21 | 22 | expect(window.gumshoe.ready).to.be.a('object'); 23 | expect(window.gumshoe.ready.promise).to.be.a('object'); 24 | expect(window.gumshoe.ready.promise.then).to.be.a('function'); 25 | }); 26 | 27 | it('should wait for Gumshoe, execute when Gumshoe resolves.', function (done) { 28 | 29 | window.gumshoe.ready.promise.then(function () { 30 | done(); 31 | }); 32 | }); 33 | 34 | }); 35 | -------------------------------------------------------------------------------- /test/runner-dist.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Test: Gumshoe 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /test/runner.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Test: Gumshoe 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /test/sessions/sessions-runner.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Test: Gumshoe 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /test/sessions/sessions-specs.js: -------------------------------------------------------------------------------- 1 | 2 | describe('Gumshoe Sessions', function() { 3 | 4 | this.timeout(5000); 5 | 6 | var sessionId, 7 | lastSessionId, 8 | sessionTimeout = 100, 9 | utmMedium = ''; 10 | 11 | function sessionFn (timestamp, difference, query) { 12 | if (difference > (sessionTimeout)) { 13 | // console.log('sessionFn: true', difference, sessionTimeout); 14 | return true; 15 | } 16 | 17 | // console.log('sessionFn: false', difference, sessionTimeout); 18 | return false; 19 | } 20 | 21 | it('should live in the global namespace', function () { 22 | expect(window.gumshoe).to.exist; 23 | 24 | gumshoe({ 25 | transport: 'spec-transport', 26 | sessionFn: sessionFn 27 | }); 28 | 29 | sessionId = lastSessionId = gumshoe._.storage('uuid'); 30 | }); 31 | 32 | it('should have a uuid in session storage', function (done) { 33 | 34 | setTimeout(function () { 35 | gumshoe.send('page.view'); 36 | }, 200); 37 | 38 | setTimeout(function () { 39 | lastSessionId = gumshoe._.storage('uuid'); 40 | 41 | expect(sessionId).to.not.equal(lastSessionId); 42 | 43 | sessionId = lastSessionId; 44 | 45 | done(); 46 | }, 300); 47 | 48 | }); 49 | 50 | it('should timeout the session and create a new uuid', function (done) { 51 | 52 | setTimeout(function () { 53 | gumshoe.send('page.view'); 54 | }, 200); 55 | 56 | setTimeout(function () { 57 | lastSessionId = gumshoe._.storage('uuid'); 58 | 59 | expect(sessionId).to.not.equal(lastSessionId); 60 | 61 | done(); 62 | }, 300); 63 | 64 | }); 65 | 66 | it('should create a new uuid with utm change', function (done) { 67 | 68 | sessionTimeout = 1000; 69 | 70 | var oldParse = gumshoe._.queryString.parse; 71 | 72 | gumshoe._.queryString.parse = function (query) { 73 | var result = oldParse.call(this, query); 74 | 75 | result.utm_medium = 'changed'; 76 | 77 | return result; 78 | } 79 | 80 | // sanity check our values before hand 81 | expect(gumshoe._.storage('utm')).to.exist; 82 | expect(gumshoe._.storage('utm').medium).to.exist; 83 | expect(gumshoe._.storage('utm').medium).to.equal(''); 84 | 85 | setTimeout(function () { 86 | gumshoe.send('page.view'); 87 | }, 200); 88 | 89 | setTimeout(function () { 90 | lastSessionId = gumshoe._.storage('uuid'); 91 | 92 | expect(gumshoe._.storage('utm').medium).to.equal('changed'); 93 | expect(sessionId).to.not.equal(lastSessionId); 94 | 95 | done(); 96 | }, 300); 97 | 98 | }); 99 | 100 | }); 101 | -------------------------------------------------------------------------------- /test/specs.js: -------------------------------------------------------------------------------- 1 | 2 | describe('Gumshoe', function() { 3 | 4 | this.timeout(5000); 5 | 6 | var data; 7 | 8 | it('should live in the global namespace', function () { 9 | expect(window.gumshoe).to.exist; 10 | }); 11 | 12 | it('should expose properties', function () { 13 | expect(gumshoe.version).to.exist; 14 | expect(gumshoe.options).to.exist; 15 | 16 | gumshoe({ transport: 'spec-transport-legacy', queueTimeout: 1000 }); 17 | }); 18 | 19 | it('should expose internal properties', function () { 20 | expect(gumshoe._).to.exist; 21 | expect(gumshoe._.storage).to.exist; 22 | expect(gumshoe._.transports).to.exist; 23 | expect(gumshoe._.transports['spec-transport']).to.exist; 24 | expect(gumshoe._.transports['spec-transport-error']).to.exist; 25 | expect(gumshoe._.transports['spec-transport-legacy']).to.exist; 26 | }); 27 | 28 | it('should set configuration', function () { 29 | expect(gumshoe.options.queueTimeout).to.equal(1000); 30 | expect(gumshoe.options.transport).to.include('spec-transport-legacy'); 31 | }); 32 | 33 | it('should expose methods', function () { 34 | expect(gumshoe.send).to.exist; 35 | expect(gumshoe.transport).to.exist; 36 | expect(gumshoe.uuid).to.exist; 37 | expect(gumshoe._.collect).to.exist; 38 | }); 39 | 40 | it('should store the client uuid', function () { 41 | var store = gumshoe._.localStorage; 42 | expect(store('clientUuid')).to.exist; 43 | expect(store('clientUuid')).to.not.be.empty; 44 | }); 45 | 46 | it('should collect data', function () { 47 | data = gumshoe._.collect(); 48 | 49 | expect(data).to.exist; 50 | }); 51 | 52 | it('should collect basic data', function () { 53 | 54 | expect(data.characterSet).to.equal('UTF-8'); 55 | 56 | if (window.callPhantom) { 57 | expect(data.colorDepth).to.equal('32'); 58 | } 59 | 60 | expect(data.cookie).to.exist; 61 | expect(data.googleClickId).to.exist; 62 | expect(data.hash).to.exist; 63 | expect(data.host).to.exist; 64 | expect(data.hostName).to.exist; 65 | expect(data.ipAddress).to.exist; 66 | 67 | if (window.callPhantom) { 68 | expect(data.javaEnabled).to.be.false; 69 | } 70 | else { 71 | expect(data.javaEnabled).to.be.true; 72 | } 73 | 74 | expect(data.language).to.exist; 75 | expect(data.loginKey).to.exist; 76 | expect(data.origin).to.equal('file://'); 77 | expect(data.path).to.exist; 78 | 79 | expect(data.platform).to.exist; 80 | 81 | // this test should be run manually in a browser. 82 | // unfortunately faking plugin data isn't reliable 83 | // and is difficult. 84 | if (!window.callPhantom) { 85 | expect(data.plugins).to.have.length.above(0); 86 | } 87 | else { 88 | expect(data.plugins).to.have.length(0); 89 | } 90 | 91 | expect(data.port).to.equal(80); 92 | expect(data.promotionKey).to.exist; 93 | expect(data.protocol).to.equal('file:'); 94 | expect(data.queryString).to.exist; 95 | expect(data.referer).to.exist; 96 | expect(data.title).to.equal('Test: Gumshoe'); 97 | 98 | expect(data.url).to.have.length.above(0); 99 | expect(true).to.equal(data.url.indexOf('test/runner.html') > 0 || data.url.indexOf('test/runner-dist.html') > 0); 100 | 101 | expect(data.userAgent).to.have.length.above(0); 102 | 103 | if (window.callPhantom) { 104 | expect(data.userAgent).to.have.string('PhantomJS'); 105 | } 106 | }); 107 | 108 | it('should collect screen data', function () { 109 | expect(data.screenAvailHeight).to.be.above(0); 110 | expect(data.screenAvailWidth).to.be.above(0); 111 | expect(data.screenHeight).to.be.above(0); 112 | expect(data.screenOrientationAngle).to.exist; 113 | expect(data.screenOrientationType).to.exist; 114 | 115 | if (window.callPhantom) { 116 | expect(data.screenPixelDepth).to.equal('32'); 117 | } 118 | 119 | expect(data.screenResolution).to.have.length.above(0); 120 | expect(data.screenWidth).to.be.above(0); 121 | }); 122 | 123 | it('should collect utm data', function () { 124 | expect(data.utmCampaign).to.exist; 125 | expect(data.utmContent).to.exist; 126 | expect(data.utmMedium).to.exist; 127 | expect(data.utmSource).to.exist; 128 | expect(data.utmTerm).to.exist; 129 | }); 130 | 131 | it('should collect viewport data', function () { 132 | expect(data.viewportHeight).to.be.above(0); 133 | expect(data.viewportResolution).to.have.length.above(0); 134 | expect(data.viewportWidth).to.be.above(0); 135 | }); 136 | 137 | it('should fetch data from the transport.map method', function () { 138 | data = gumshoe._.transports['spec-transport'].map(data); 139 | 140 | expect(data.newProp).to.equal(1); 141 | expect(data.ipAddress).to.equal('192.168.1.1'); 142 | }); 143 | 144 | it('should have a uuid in session storage', function () { 145 | expect(gumshoe._.storage('uuid')).to.exist; 146 | expect(gumshoe._.storage('uuid')).to.have.length.above(0); 147 | }); 148 | 149 | it('should queue events', function (done) { 150 | gumshoe.send('page.view', { foo: 'bar'}); 151 | 152 | expect(gumshoe._.queue).to.have.length(1); 153 | 154 | var nevent = gumshoe._.queue[0]; 155 | 156 | expect(nevent).to.exist; 157 | expect(nevent.eventName).to.equal('page.view'); 158 | expect(nevent.transportName).to.equal('spec-transport-legacy'); 159 | expect(nevent.data).to.exist; 160 | expect(nevent.data.clientUuid).to.not.be.empty; 161 | expect(nevent.data.eventData).to.exist; 162 | expect(nevent.data.pageData).to.exist; 163 | expect(nevent.data.timestamp).to.be.above(0); 164 | expect(nevent.data.timezoneOffset).to.exist; 165 | expect(nevent.data.uuid).to.have.length.above(0); 166 | 167 | expect(nevent.data.eventName).to.equal('page.view'); 168 | expect(JSON.parse(nevent.data.eventData).foo).to.equal('bar'); 169 | expect(nevent.data.newProp).to.equal(1); 170 | expect(nevent.data.ipAddress).to.equal('192.168.1.1'); 171 | 172 | setTimeout(function () { 173 | // queue should be empty after our test event has been 'sent' 174 | expect(gumshoe._.queue).to.have.length(0); 175 | done(); 176 | }, 1100); 177 | }); 178 | 179 | it('should use the new style transport', function (done) { 180 | 181 | gumshoe({ transport: 'spec-transport', queueTimeout: 1000 }); 182 | 183 | gumshoe.send('page.view', { foo: 'bar'}); 184 | 185 | expect(gumshoe._.queue).to.have.length(1); 186 | 187 | setTimeout(function () { 188 | // queue should be empty after our test event has been 'sent' 189 | expect(gumshoe._.queue).to.have.length(0); 190 | done(); 191 | }, 1100); 192 | }); 193 | 194 | it('should handle transports that throw errors', function (done) { 195 | 196 | gumshoe({ transport: 'spec-transport-error', queueTimeout: 1000 }); 197 | 198 | gumshoe.send('page.view', { foo: 'bar'}); 199 | 200 | expect(gumshoe._.queue).to.have.length(1); 201 | 202 | setTimeout(function () { 203 | // queue should be empty after our test event has been 'sent' 204 | expect(gumshoe._.queue).to.have.length(1); 205 | done(); 206 | }, 1100); 207 | 208 | }); 209 | 210 | }); 211 | --------------------------------------------------------------------------------