/* * Copyright (c) 2015-2016, Willem Cazander * All rights reserved. * * Redistribution and use in source and binary forms, with or without modification, are permitted provided * that the following conditions are met: * * * Redistributions of source code must retain the above copyright notice, this list of conditions and the * following disclaimer. * * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and * the following disclaimer in the documentation and/or other materials provided with the distribution. * * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF * MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL * THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT * OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR * TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ /* jslint browser: true */ /* global angular,define */ /** * FFSpaLoader is an assets loader for single page applications. * Its build around the concept the there is only a single static index.html which * synces all its assets to local cache for offline and or fast page loads. * * Features: * - Assets caching for offline use. * - Assets hashing for fast syncing. * - Assets types: js,css,cssData * - Caching backends: null,localStorage,webSqlDB,sqllite * - Server url question ui. * - Loader error ui. * - Build-in Cordova booting. * - Build-in AngularJS booting. * * @module FFSpaLoader */ (function (root, factory) { if ( typeof define === 'function' && define.amd ) { define('FFSpaLoader', factory(root)); } else if ( typeof exports === 'object' ) { module.exports = factory(root); } else { root.FFSpaLoader = factory(root); } })(this || window, /** @lends module:FFSpaLoader */ function (rootWindow) { 'use strict'; var options = { debug: { enable: false, handler: null, // auto filled prefix: 'FFSpaLoader.' }, error: { handler: null, // auto filled title: 'Loader Error', style: '.ffError { margin: auto;width: 90%;border: 3px solid red;padding-left: 1em;padding-bottom: 1em;}' }, boot: { cordova: { enable: true, timeout: 4096, // FIXME: Test really slow devices -> increase ? or add support -1 for disable or ? flag: 'FFCordovaDevice' }, angular: { enable: true, modules: [] }, }, server: { url: null, assets: null, timeout: 4096, flag: 'FFServerUrl', header: { request: { // TODO: add header support 'X-FFSpaLoader': '42' }, response: { } }, question: { transport: 'http://', title: 'Server', text: 'Please provide the server name;', // TODO: rename .ffAskUrl style: '.ffAskUrl { font-size: 1em;margin: auto;width: 90%;border: 3px solid #73AD21;padding-left: 1em;padding-bottom: 1em;} .ffAskUrl > div {font-size: 0.8em;color: #ccc;} .ffAskUrl > div > * {} .ffAskUrl > div > input {} .ffAskUrlError{ color: red}', } }, cache: { meta: null, js: null, css: null, cssData: null } }; var cacheDB = null; // single instance for websql var factory = { detect: { localStorage: function() { try { var testData = 'localStorageDetect'; rootWindow.localStorage.setItem(testData, testData); rootWindow.localStorage.removeItem(testData); return true; } catch(e) { return false; } }, openDatabase: function() { return 'openDatabase' in rootWindow; }, sqlitePlugin: function() { return 'sqlitePlugin' in rootWindow; }, cordova: function() { return 'cordova' in rootWindow; }, cordovaDevice: function() { return options.boot.cordova.flag in rootWindow; }, mobileAgent: function() { return rootWindow.navigator !== undefined && rootWindow.navigator.userAgent.match(/(iPhone|iPod|iPad|Android|BlackBerry|IEMobile)/); } }, cache: { localStorage: function() { return { cacheGetValue: function(key, cb) { try { var dataRaw = rootWindow.localStorage.getItem(key); var data = JSON.parse(dataRaw); cb(null, data); } catch(e) { cb(e); } }, cacheSetValue: function(key, value, cb) { try { rootWindow.localStorage.setItem(key,JSON.stringify(value)); cb(null); } catch(e) { cb(e); } }, cacheDeleteValue: function(key, cb) { try { rootWindow.localStorage.removeItem(key); cb(null); } catch(e) { cb(e); } } }; }, websql: function(opt) { if (opt === undefined) { opt = {}; } if (opt.name === undefined) { opt.name = 'ffSpaLoader.db'; } if (opt.size === undefined) { opt.size = -1; } if (opt.version === undefined) { opt.version = '1.0'; } if (opt.openDatabase === undefined) { opt.openDatabase = rootWindow.openDatabase; } var nullDataHandler = function(cb) { return function () { cb(null); }; }; var sqlErrorHandler = function(cb) { return function (tx, err) { cb(err.message+' code: '+err.code); }; }; var cacheDBInit = function(cb) { cacheDB.transaction(function(tx) { var query = 'CREATE TABLE cache_store(id INTEGER PRIMARY KEY AUTOINCREMENT, key TEXT NOT NULL, value TEXT NOT NULL)'; utilDebug('websql.init query '+query); tx.executeSql(query, [], function(tx) { var query = 'CREATE UNIQUE INDEX cache_store__key__udx ON cache_store (key)'; utilDebug('websql.init query '+query); tx.executeSql(query, [], function() {cb(null);}, sqlErrorHandler(cb)); }, sqlErrorHandler(cb)); }); }; return { cacheOpen: function(cb) { if (cacheDB !== null) { cb(null); // open once. return; } cacheDB = rootWindow.openDatabase(opt.name, opt.version, opt.name, opt.size); cacheDB.transaction(function(tx) { var query = 'SELECT value FROM cache_store WHERE key = \"test-for-table\"'; utilDebug('websql.cacheOpen query '+query); tx.executeSql(query, [], function() { cb(null); }, function() { cacheDBInit(cb); }); }); }, cacheGetValue: function(key, cb) { cacheDB.transaction(function(tx) { var query = 'SELECT value FROM cache_store WHERE key = ?'; utilDebug('websql.cacheGetValue query '+query); tx.executeSql(query,[key], function(tx, res) { if (res.rows.length === 0) { cb(null, null); } else { var value = res.rows.item(0).value; cb(null, JSON.parse(value)); } }, sqlErrorHandler(cb)); }); }, cacheSetValue: function(key, value, cb) { cacheDB.transaction(function(tx) { var query = 'SELECT value FROM cache_store WHERE key = ?'; utilDebug('websql.cacheSetValue query '+query); tx.executeSql(query,[key], function(tx, res) { if (res.rows.length === 0) { var queryInsert = 'INSERT INTO cache_store (key,value) VALUES (?,?)'; utilDebug('websql.cacheSetValue query '+queryInsert); tx.executeSql(queryInsert, [key,JSON.stringify(value)], nullDataHandler(cb), sqlErrorHandler(cb)); } else { var queryUpdate = 'UPDATE cache_store SET value = ? WHERE key = ?'; utilDebug('websql.cacheSetValue query '+queryUpdate); tx.executeSql(queryUpdate, [JSON.stringify(value),key], nullDataHandler(cb), sqlErrorHandler(cb)); } }, sqlErrorHandler(cb)); }); }, cacheDeleteValue: function(key, cb) { cacheDB.transaction(function(tx) { var query = 'DELETE FROM cache_store WHERE key = ?'; utilDebug('websql.cacheDeleteValue query '+query); tx.executeSql(query, [key], function () { setTimeout(function() {cb(null);}); // return next tick so transaction is flushed before location.reload }, sqlErrorHandler(cb)); }); } }; }, } }; /** * Prints the debug message with prefix to the options.debug.handler if options.debug.enable is true. * @param {String} message The message to log. * @private */ var utilDebug = function (message) { if (options.debug.enable !== true) { return; } options.debug.handler(options.debug.prefix+message); }; /** * The default error handler which renders the error in the browser. * @param {String|Error} err The error message. * @private */ var utilErrorHandler = function(err) { utilDebug('utilErrorHandler error '+err); // TODO: Add Error object support var rootTag = document.createElement('div'); rootTag.setAttribute('class','ffError'); var cssTag = document.createElement("style"); cssTag.type = "text/css"; cssTag.innerHTML = options.error.style; rootTag.appendChild(cssTag); var titleTag = document.createElement('h1'); titleTag.appendChild(document.createTextNode(options.error.title)); rootTag.appendChild(titleTag); var questionTag = document.createElement('p'); questionTag.appendChild(document.createTextNode(err)); rootTag.appendChild(questionTag); document.getElementsByTagName('body')[0].appendChild(rootTag); }; /** * Fetches an url resource with a XMLHttpRequest. * @param {String} url The url to fetch. * @param {function} cb The callback for error and request if ready. * @private */ var utilHttpFetch = function (url, cb) { var startTime = new Date().getTime(); var httpRequest = new XMLHttpRequest(); httpRequest.onreadystatechange = function() { if (httpRequest.readyState === 4 && httpRequest.status === 200) { utilDebug('utilHttpFetch url \"'+url+'\" done in '+(new Date().getTime()-startTime)+' ms.'); cb(null, httpRequest); } else if (httpRequest.readyState === 4) { cb('Wrong status '+httpRequest.status); } }; httpRequest.open('GET', url, true); httpRequest.timeout = options.server.timeout; // ieX: after open() httpRequest.ontimeout = function() { cb('timeout after '+options.server.timeout+' of url '+url); }; httpRequest.send(); }; var cacheGetService = function (type) { if (options.cache[type]) { return options.cache[type]; } return null; }; var cacheHasService = function (type) { return cacheGetService(type) !== null; }; var cacheCheckType = function (type, cb, action) { cacheHasService(type)?action():cb('No caching for '+type); }; var cacheGetValue = function(type, key , cb) { cacheCheckType(type, cb, function() { var cacheKey = type+'_'+key; utilDebug('cacheGetValue key '+cacheKey); cacheGetService(type).cacheGetValue(cacheKey,cb); }); }; var cacheSetValue = function(type, key, value, cb) { cacheCheckType(type, cb, function() { var cacheKey = type+'_'+key; utilDebug('cacheSetValue key '+cacheKey); cacheGetService(type).cacheSetValue(cacheKey,value,cb); }); }; var cacheDeleteValue = function(type, key, cb) { cacheCheckType(type, cb, function() { var cacheKey = type+'_'+key; utilDebug('cacheDeleteValue key '+cacheKey); cacheGetService(type).cacheDeleteValue(cacheKey,cb); }); }; // var cleanupCache = function (resources, cb) { // utilDebug('cleanupCache TODO cacheList '+0+' resources '+resources.length); // // TODO: impl for removes in resource lists // cb(null); // }; var injectResourceData = function(resource, data, cb) { utilDebug('injectResourceData resource '+JSON.stringify(resource)+' data '+data.length); var tag = null; if (resource.type === 'css' || resource.type === 'cssData') { tag = document.createElement('style'); tag.type = 'text/css'; } if (resource.type === 'js') { tag = document.createElement('script'); tag.type = 'text/javascript'; } tag.appendChild(document.createTextNode(data)); document.getElementsByTagName('head')[0].appendChild(tag); //var ref = document.getElementsByTagName('script')[0]; //ref.parentNode.insertBefore(tag, ref); // note in reverse order cb(null); }; var storeResourceKey = function (resource, cb) { // Add all cache keys in central list so we can clear the cache item if resources are removed. var cacheKey = resource.type+'_keys'; cacheGetValue('meta',cacheKey,function (err,value) { // TODO: move to resource.type if (err !== null) { cb(err); return; } if (value === null) { value = []; } value.push(resource.hash); cacheSetValue('meta',cacheKey,value, cb); }); }; var storeResource = function (resource, httpRequest, cb) { utilDebug('storeResource url '+resource.url+' hash '+resource.hash); var item = { resource: resource, data: httpRequest.responseText }; cacheSetValue(resource.type, resource.hash, item , function(err) { if (err !== null) { cb(err); } else { storeResourceKey(resource,cb); } }); }; var loadResource = function (resource, cb) { var resourceUrl = resource.url; if (resourceUrl.indexOf('http') === -1) { resourceUrl = options.server.url + resourceUrl; } utilDebug('loadResource '+JSON.stringify(resource)); if (cacheHasService(resource.type)) { cacheGetValue(resource.type,resource.hash,function(err, value) { if (err !== null) { return cb(err); } if (value === null) { utilDebug('loadResource cache miss'); // + hash mismatch as its the key } else if (value.resource === undefined) { utilDebug('loadResource cache wrong obj'); } else { utilDebug('loadResource cache hit'); injectResourceData(value.resource,value.data,cb); return; } utilHttpFetch(resourceUrl, function(err, httpRequest) { if (err !== null) { return cb(err); } storeResource(resource, httpRequest, function (err) { if (err !== null) { return cb(err); } injectResourceData(resource,httpRequest.responseText,cb); }); }); }); } else { // note: was links but now download + inject so order stays sequenced utilHttpFetch(resourceUrl, function(err, httpRequest) { if (err !== null) { return cb(err); } injectResourceData(resource,httpRequest.responseText,cb); }); } }; var loadResources = function(resources, cb) { var startTime = new Date().getTime(); utilDebug('loadResources'); var resourceStack = resources; var resourceLoader = function(err) { if (err !== null) { return cb(err); } resourceStack = resourceStack.slice(1); if (resourceStack.length === 0) { utilDebug('loadResources done in '+(new Date().getTime()-startTime)+' ms.'); cb(null); } else { loadResource(resourceStack[0],resourceLoader); } }; loadResource(resourceStack[0],resourceLoader); }; var injectResources = function(resources, cb) { var startTime = new Date().getTime(); utilDebug('injectResources'); resources.forEach(function (resource) { cacheGetValue(resource.type,resource.hash,function(err,item) { // TODO injectResourceData var tag = null; if (resource.type === 'css' || resource.type === 'cssData') { tag = document.createElement('style'); tag.type = 'text/css'; } if (resource.type === 'js') { tag = document.createElement('script'); tag.type = 'text/javascript'; } utilDebug('injectResources from '+JSON.stringify(resource)); tag.appendChild(document.createTextNode(item.data)); //document.getElementsByTagName('head')[0].appendChild(tag); var ref = document.getElementsByTagName('script')[0]; ref.parentNode.insertBefore(tag, ref); // note in reverse order }); }); utilDebug('injectResources done in '+(new Date().getTime()-startTime)+' ms.'); cb(null); // FIXME async }; /** * Starts loader by downloading resource list or using cache version to * load/refresh/inject the resources. * * @param {function} cb Callback gets called when loader is done. * @private */ var startLoader = function (cb) { if (options.server.url === null) { cb('Missing options.server.url'); return; } if (options.server.assets === null) { cb('Missing options.server.assets'); return; } rootWindow[options.server.flag] = options.server.url; var resourcesUrl = options.server.url + options.server.assets; utilDebug('startLoader assets \"'+resourcesUrl+'\"'); utilHttpFetch(resourcesUrl, function(err, httpRequest) { if (err !== null) { utilDebug('startLoader fetch error '+err); if (cacheHasService('meta')) { cacheGetValue('meta','server_resources',function(err, value) { if (err !== null) { cb(err); } else if (value === null) { cb('Have no cache of server resouces from '+options.server.url); } else { injectResources(value, cb); } }); } else { cb('Could not fetch server resouces from '+options.server.url); } return; } var resources = []; JSON.parse(httpRequest.responseText).data.resources.forEach(function (r) { resources.push(r); }); utilDebug('startLoader resources '+resources.length); if (cacheHasService('meta')) { cacheSetValue('meta','server_resources',resources, function (err) { if (err !== null) { return cb(err); } loadResources(resources, cb); }); } else { loadResources(resources, cb); } }); }; /** * Validates the user input url by downloading it. * * @param {function} cb Callback gets called when loader is done. * @param {Element} deleteTag The dom element to remove from the body tag after success. * @private */ var askUrlValidate = function (cb,deleteTag) { var inputTag = document.getElementById('serverInput'); var inputErrorTag = document.getElementById('serverInputError'); while (inputErrorTag.firstChild) { inputErrorTag.removeChild(inputErrorTag.firstChild); // clear error } options.server.url = ''; if (options.server.question.transport !== undefined) { options.server.url += options.server.question.transport; } options.server.url += inputTag.value; var resourcesUrl = options.server.url + options.server.assets; utilDebug('askUrlStart check assets '+resourcesUrl); // TODO: add+check headers utilHttpFetch(resourcesUrl,function(err, httpRequest) { if (err !== null) { inputErrorTag.appendChild(document.createTextNode('Error could not get data.')); return; } if (httpRequest.responseText.length === 0) { inputErrorTag.appendChild(document.createTextNode('Error Got empty data.')); return; } document.getElementsByTagName('body')[0].removeChild(deleteTag); if (cacheHasService('meta')) { cacheSetValue('meta','server_url',options.server.url, function(err) { if (err !== null) { return cb(err); } startLoader(cb); }); } else { startLoader(cb); } }); }; /** * Creates the ask url ui. * * @param {function} cb Callback gets called when loader is done. * @private */ var askUrl = function (cb) { utilDebug('askUrl create ui'); var rootTag = document.createElement('div'); rootTag.setAttribute('class','ffAskUrl'); var cssTag = document.createElement("style"); cssTag.type = "text/css"; cssTag.innerHTML = options.server.question.style; rootTag.appendChild(cssTag); var titleTag = document.createElement('h1'); titleTag.appendChild(document.createTextNode(options.server.question.title)); rootTag.appendChild(titleTag); var questionTag = document.createElement('p'); questionTag.appendChild(document.createTextNode(options.server.question.text)); rootTag.appendChild(questionTag); var formTag = document.createElement('div'); rootTag.appendChild(formTag); if (options.server.question.transport !== undefined) { var transportTag = document.createElement('label'); rootTag.setAttribute('for','serverInput'); transportTag.appendChild(document.createTextNode(options.server.question.transport)); formTag.appendChild(transportTag); } var inputTag = document.createElement('input'); inputTag.type = 'text'; inputTag.id = 'serverInput'; inputTag.setAttribute('autofocus',''); inputTag.setAttribute('onkeydown','if (event.keyCode == 13) {document.getElementById(\'serverSubmit\').click()}'); formTag.appendChild(inputTag); var submitLineTag = document.createElement('br'); formTag.appendChild(submitLineTag); var submitTag = document.createElement('input'); submitTag.id = 'serverSubmit'; submitTag.type = 'submit'; submitTag.value = 'Start'; submitTag.onclick = function() {askUrlValidate(cb,rootTag);}; formTag.appendChild(submitTag); var serverErrorTag = document.createElement('div'); serverErrorTag.id = 'serverInputError'; serverErrorTag.setAttribute('class','ffAskUrlError'); formTag.appendChild(serverErrorTag); document.getElementsByTagName('body')[0].appendChild(rootTag); }; var startCacheType = function (type,cb) { if (options.cache[type] !== null && options.cache[type] === false) { utilDebug('startCacheType '+type+' disabled'); options.cache[type] = null; return cb(null); } if (options.cache[type] === null) { if (factory.detect.cordovaDevice() && factory.detect.sqlitePlugin()) { utilDebug('startCacheType auto sqlitePlugin for '+type); options.cache[type] = factory.cache.websql({openDatabase: rootWindow.sqlitePlugin}); } else if (factory.detect.openDatabase()) { utilDebug('startCacheType auto openDatabase for '+type); options.cache[type] = factory.cache.websql(); } else if (factory.detect.localStorage()) { utilDebug('startCacheType auto localStorage for '+type); options.cache[type] = factory.cache.localStorage(); } else { utilDebug('startCacheType '+type+' none'); } } if (options.cache[type] !== null && typeof options.cache[type].cacheOpen === 'function') { options.cache[type].cacheOpen(cb); } else { cb(null); } }; var startCache = function (cb) { startCacheType('meta', function(err) { if (err !== null) { return cb(err); } startCacheType('js', function(err) { if (err !== null) { return cb(err); } startCacheType('css', function(err) { if (err !== null) { return cb(err); } startCacheType('cssData', cb); }); }); }); }; /** * Starts the loader. * * @param {function} cb Callback gets called when loader is done. */ var start = function (cbArgu) { var startTime = new Date().getTime(); var cb = function(err) { if (err !== null) { options.error.handler(err); } else { utilDebug('start done in '+(new Date().getTime()-startTime)+' ms.'); bootAngular(function(err) { if (err !== null) { return options.error.handler(err); } if (typeof cbArgu === 'function') { cbArgu(); } }); } }; if (options.debug.enable === true) { var optionsKeys = Object.keys(options); for (var keyId in optionsKeys) { var key = optionsKeys[keyId]; utilDebug('start config '+key+' '+JSON.stringify(options[key])); } } bootCordova(function(err) { if (err !== null) { return cb(err); } startCache(function(err) { if (err !== null) { return cb(err); } if (options.server.url !== null) { startLoader(cb); return; } if (cacheHasService('meta')) { cacheGetValue('meta','server_url',function(err, value) { if (err !== null) { cb(err); } else if (value === undefined || value === null || value === '') { askUrl(cb); } else { options.server.url = value; startLoader(cb); } }); } else { askUrl(cb); } }); }); }; var clearServerUrl = function(cb) { if (cb === undefined) { cb = function() {}; } if (cacheHasService('meta')) { cacheDeleteValue('meta','server_url',cb); } else { cb(null); } }; var bootAngular = function(cb) { if (options.boot.angular.enable !== true) { utilDebug('bootAngular disabled by options'); return cb(null); } if (options.boot.angular.modules.length === 0) { utilDebug('bootAngular disabled by no modules'); return cb(null); } utilDebug('bootAngular start '+options.boot.angular.modules); angular.bootstrap(document, options.boot.angular.modules); cb(null); }; /** * Helper for cordova applications which want to use the sqllite plugin as cache. * Note: On none cordova page it will callback directly. * Note: On none cordova device page is will timeout. * * @param {function} cb Callback gets called device is ready to use. * @private */ var bootCordova = function(cb) { if (options.boot.cordova.enable !== true) { utilDebug('bootCordova disabled by options'); return cb(null); } if (factory.detect.cordova() !== true) { utilDebug('bootCordova disabled by detect'); return cb(null); } var startTime = new Date().getTime(); var bootOnce = function() { var callback = cb; cb = null; utilDebug('bootCordova done in '+(new Date().getTime()-startTime)+' ms.'); callback(); }; utilDebug('bootCordova timeout '+options.boot.cordova.timeout); setTimeout ( function () { utilDebug('bootCordova timeout'); bootOnce(); }, options.boot.cordova.timeout); document.addEventListener("deviceready", function () { rootWindow[options.boot.cordova.flag] = true; utilDebug('bootCordova '+options.boot.cordova.flag); bootOnce(); }, false); }; // Auto fill handlers and return public object. options.debug.handler = function(msg) {console.log(msg);}; options.error.handler = utilErrorHandler; return { options: options, factory: factory, start: start, clearServerUrl: clearServerUrl }; });