/* * 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,sqlitePlugin */ /** * 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. * * @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'; /** * The options to customize the loader. */ var options = { debug: { enable: false, handler: null, // auto filled prefix: 'FFSpaLoader.' }, error: { handler: null, // auto filled title: 'Loader ', style: 'body {color: #EFF0F1;background: #484948;} .ffError { margin: 3em;border-left: 0.3em solid #B55858;border-radius: 1em;padding: 0em 1em 0.3em 1em;}' }, boot: { cordova: { enable: true, timeout: -1, flag: 'FFCordovaDevice' }, angular: { enable: true, modules: [] }, }, server: { url: null, assets: null, timeout: 4096, flag: 'FFServerUrl', header: { request: { 'X-FFSpaLoader': 'sync' }, response: { // TODO: add header check support } } }, question: { transport: 'http://', title: 'Question', submit: 'Start', size: 32, text: 'Please provide the server name', style: 'body {color: #EFF0F1;background: #484948;} .ffQuestion { margin: 3em;border-left: 0.3em solid #3F68AD;border-radius: 1em;padding: 0em 1em 0.3em 1em;} .ffQuestion > div > input {margin: 0.4em;padding: 0.4em; line-height: 2em;background-color: #454442;color: #EFF0F1;border: none;border-radius: 0.4em;outline: none;min-width: 5em;} .ffQuestion > div > input:focus {border: none;} .ffQuestionError{color: #B55858;} .ffQuestionLoad {transition: all 0.5s ease;color: #484948;} .ffQuestionLoad > div > input {background-color: #484948;color: #484948;}', validate: { min: { value: 3, message: 'Server name is to short.' }, max: { value: 255, message: 'Server name is to long.' }, regex: { value: '^([a-zA-Z0-9\.\:])*$', message: 'Server name is invalid.' } } }, cache: { meta: null, js: null, css: null, cssData: null } }; /** * Use single instance for websql * @private */ var cacheDB = null; /** * The factory which contains detection helpers and cache backend builders. */ var factory = { detect: { localStorage: function() { try { var testData = 'localStorageDetect'; rootWindow.localStorage.setItem(testData, testData); // throws err in private browsing mode 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; } }, 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'; } if (opt.size === undefined) { opt.size = 4 * 1024 * 1024; } // reg 4MB let user do higher if (opt.version === undefined) { opt.version = '1.0'; } if (opt.table === undefined) { opt.table = 'cache_store'; } if (opt.open === undefined) { opt.open = function(dbOpt) { return rootWindow.openDatabase(dbOpt.name, dbOpt.version, dbOpt.name, dbOpt.size); }; } var nullDataHandler = function(cb) { return function () { cb(null); }; }; var executeSql = function(tx, query, values, resultHandler, errorHandler) { utilDebug('websql.executeSql '+query); tx.executeSql(query, values, resultHandler, function(tx,err) { errorHandler(new Error('Code: '+err.code+' '+err.message+' by query: '+query)); }); }; var cacheDBInit = function(cb) { cacheDB.transaction(function(tx) { var query = 'CREATE TABLE '+opt.table+'(id INTEGER PRIMARY KEY AUTOINCREMENT, key TEXT NOT NULL, value TEXT NOT NULL)'; executeSql(tx, query, [], function(tx) { executeSql(tx, 'CREATE UNIQUE INDEX '+opt.table+'__key__udx ON '+opt.table+' (key)', [], nullDataHandler(cb), cb); }, cb); }); }; return { cacheOpen: function(cb) { if (cacheDB !== null) { return cb(null); // open once. } utilDebug('websql.cacheOpen '+JSON.stringify(opt)); try { cacheDB = opt.open(opt); } catch(e) { return cb(e); } cacheDB.transaction(function(tx) { executeSql(tx,'SELECT value FROM '+opt.table+' WHERE key = \"if-err-init\"', [], function() { cb(null); }, function() { cacheDBInit(cb); }); }); }, cacheGetValue: function(key, cb) { cacheDB.transaction(function(tx) { executeSql(tx, 'SELECT value FROM '+opt.table+' WHERE key = ?',[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)); } }, cb); }); }, cacheSetValue: function(key, value, cb) { cacheDB.transaction(function(tx) { executeSql(tx, 'SELECT value FROM '+opt.table+' WHERE key = ?',[key], function(tx, res) { if (res.rows.length === 0) { var queryInsert = 'INSERT INTO '+opt.table+' (key,value) VALUES (?,?)'; executeSql(tx, queryInsert, [key,JSON.stringify(value)], nullDataHandler(cb), cb); } else { var queryUpdate = 'UPDATE '+opt.table+' SET value = ? WHERE key = ?'; executeSql(tx, queryUpdate, [JSON.stringify(value),key], nullDataHandler(cb), cb); } }, cb); }); }, cacheDeleteValue: function(key, cb) { cacheDB.transaction(function(tx) { executeSql(tx, 'DELETE FROM '+opt.table+' WHERE key = ?', [key], nullDataHandler(cb), 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 {Error|String} err The error object or message. * @private */ var utilErrorHandler = function(err) { if (!(err instanceof Error)) { err = new Error(err); } utilDebug('utilErrorHandler error '+err.name+' '+err.message); var rootTag = document.createElement('div'); rootTag.setAttribute('class','ffError'); document.getElementsByTagName('body')[0].appendChild(rootTag); 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+err.name)); rootTag.appendChild(titleTag); var questionTag = document.createElement('p'); questionTag.appendChild(document.createTextNode(err.message)); rootTag.appendChild(questionTag); try { var stack = err.stack || ''; stack = stack.split('\n').map(function (line) { return line.trim()+'\n'; }); var stackText = stack.splice(stack[0] === 'Error' ? 2 : 1); var traceTag = document.createElement('pre'); traceTag.appendChild(document.createTextNode(stackText)); rootTag.appendChild(traceTag); } catch (stackError) { utilDebug('No stack: '+stackError); } }; /** * Fetches an url resource with a XMLHttpRequest. * @param {String} url The url to fetch. * @param {function} cb Error first callback when done. * @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); }; var headerKeys = Object.keys(options.server.header.request); for (var headerKeyIdx in headerKeys) { var headerKey = headerKeys[headerKeyIdx]; var headerValue = options.server.header.request[headerKey]; httpRequest.setRequestHeader(headerKey,headerValue); } httpRequest.send(); }; /** * Async helper to run each step from the stack. * @param {String} runType The runType used for debug logging. * @param {Array} stack The stack to run all steps on. * @param {function} step Stack Item + Error first callback when done function callback. * @param {function} cb Error first callback when done. * @private */ var utilRunStack = function(runType, stack, step, cb) { if (stack.length === 0) { return cb(null); } utilDebug(runType+' start'); var startTime = new Date().getTime(); var runStack = stack; var runStackStep = function(err) { if (err !== null) { return cb(err); } runStack = runStack.slice(1); if (runStack.length === 0) { utilDebug(runType+' done '+stack.length+' in '+(new Date().getTime()-startTime)+' ms.'); cb(null); } else { step(runStack[0],runStackStep); } }; step(runStack[0],runStackStep); }; 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(new Error('No caching for '+type)); }; /** * Retreive a cached value. * @param {String} type The cache type to use. * @param {String} key The key to get the value for. * @param {function} cb Error first callback + value callback. * @private */ var cacheGetValue = function(type, key , cb) { cacheCheckType(type, cb, function() { var cacheKey = type+'_'+key; utilDebug('cacheGetValue key '+cacheKey); cacheGetService(type).cacheGetValue(cacheKey,cb); }); }; /** * Store a value in cache. * @param {String} type The cache type to use. * @param {String} key The key to store the value for. * @param {String} value The value to store. * @param {function} cb Error first callback. * @private */ 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); }); }; /** * Deletes an cache value. * @param {String} type The cache type to use. * @param {String} key The key to delete the value. * @param {function} cb Error first callback. * @private */ 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, deleteAll, cb) { var typedKeys = {}; resources.forEach(function (r) { var typeKey = typedKeys[r.type]; if (typeKey === undefined) { typeKey = []; typedKeys[r.type] = typeKey; } typeKey.push(r.hash); }); var steps = []; var keys = Object.keys(typedKeys); for (var keyIdx in keys) { var type = keys[keyIdx]; if (!cacheHasService(type)) { continue; } steps.push({keys: typedKeys[type], type: type}); } utilRunStack('cleanupCacheKeys',steps, function(typeKey, cb) { cacheGetValue(typeKey.type,'keys',function (err,value) { if (err !== null) { return cb(err); } if (value === null) { return cb(null); // meta has no keys } var diff = []; for (var i=0;i= 0) { inputValueHost = inputValueRaw.split('/')[2]; } else { inputValueHost = inputValueRaw.split('/')[0]; } if (options.question.validate.min.value !== false && inputValueHost.length < options.question.validate.min.value) { inputErrorTag.appendChild(document.createTextNode(options.question.validate.min.message)); return; } if (options.question.validate.max.value !== false && inputValueHost.length > options.question.validate.max.value) { inputErrorTag.appendChild(document.createTextNode(options.question.validate.max.message)); return; } if (options.question.validate.regex.value !== false && options.question.validate.regex.value.length !== 0) { var regex = new RegExp(options.question.validate.regex.value); if (inputValueHost.match(regex) === null) { inputErrorTag.appendChild(document.createTextNode(options.question.validate.regex.message)); return; } } if (inputValueRaw.indexOf('https') === 0) { options.server.url = 'https://' + inputValueHost; // allow user to upgrade to https but not to downgrade to http } else { options.server.url = options.question.transport + inputValueHost; } var resourcesUrl = options.server.url + options.server.assets; utilDebug('askUrlStart check assets '+resourcesUrl); // TODO: 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; } deleteTag.setAttribute('class','ffQuestion ffQuestionLoad'); var clearUi = function(err) { document.getElementsByTagName('body')[0].removeChild(deleteTag); // also delete on error cb(err); }; if (cacheHasService('meta')) { cacheSetValue('meta','server_url',options.server.url, function(err) { if (err !== null) { return cb(err); } startLoader(clearUi); }); } else { startLoader(clearUi); } }); }; /** * 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','ffQuestion'); var cssTag = document.createElement('style'); cssTag.type = 'text/css'; cssTag.innerHTML = options.question.style; rootTag.appendChild(cssTag); var titleTag = document.createElement('h1'); titleTag.appendChild(document.createTextNode(options.question.title)); rootTag.appendChild(titleTag); var questionTag = document.createElement('p'); questionTag.appendChild(document.createTextNode(options.question.text)); rootTag.appendChild(questionTag); var formTag = document.createElement('div'); rootTag.appendChild(formTag); 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()}'); inputTag.setAttribute('size',options.question.size); formTag.appendChild(inputTag); var submitTag = document.createElement('input'); submitTag.id = 'serverSubmit'; submitTag.type = 'submit'; submitTag.value = options.question.submit; submitTag.onclick = function() {askUrlValidate(rootTag,cb);}; formTag.appendChild(submitTag); var serverErrorTag = document.createElement('div'); serverErrorTag.id = 'serverInputError'; serverErrorTag.setAttribute('class','ffQuestionError'); formTag.appendChild(serverErrorTag); document.getElementsByTagName('body')[0].appendChild(rootTag); }; /** * Starts an cache type by auto selecting if needed and opening it if needed. * * @param {String} type The cache type to start. * @param {function} cb Callback gets called when loader is done. * @private */ 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({open: function(dbOpt) { return sqlitePlugin.openDatabase(dbOpt.name, dbOpt.version, dbOpt.name, dbOpt.size);}}); } 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); } }; /** * Starts all cache types. * * @param {function} cb Callback gets called when loader is done. * @private */ var startCache = function (cb) { // FIXME: use dynamic loop for user defined types. 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 Optional 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.'); // last debug line TODO: move bootAngular to onjsloaded bootAngular(function(err) { if (err !== null) { return options.error.handler(err); } if (typeof cbArgu === 'function') { cbArgu(); } }); } }; utilDebug('start spa loader'); // first debug line TODO: Add version 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); } }); }); }; /** * Clears the server url. * * @param {function} cb Optional Error first callback gets called when value is deleted. */ var clearServerUrl = function(cb) { if (cb === undefined) { cb = function() {}; } if (cacheHasService('meta')) { cacheDeleteValue('meta','server_url',function(err) { if (err !== null) { return cb(err); } setTimeout(function() {cb(null);}); // return next tick so (websql) transaction is flushed before location.reload }); } else { cb(null); } }; /** * Clears the cache. * * @param {function} cb Optional Error first callback gets called when cache is cleared. */ var clearCache = function(cb2) { if (cb2 === undefined) { cb2 = function() {}; } var cb = function(err) { setTimeout(function() {cb2(err);}); // next tick to flush transactions. }; if (cacheHasService('meta')) { cacheGetValue('meta','server_resources',function(err, value) { if (err !== null) { return cb(err); } if (value === null) { return cb(new Error('No cache value')); // TODO: check this or cb(null) or do fetch ? } cleanupCache(value,true,cb); }); } else { utilHttpFetch(options.server.url + options.server.assets,function(err, httpRequest) { if (err !== null) { return cb(err); } var resources = JSON.parse(httpRequest.responseText).data.resources; cleanupCache(resources,false,cb); }); } }; /** * Boots angular modules if enabled. * * @param {function} cb Error first callback gets called done. * @private */ 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); }; /** * Boots 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 if option is set. * * @param {function} cb Error first callback gets called device is ready. * @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(null); }; if (options.boot.cordova.timeout > 0) { 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, clearCache: clearCache }; });