/* * 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. * * @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 ', style: '.ffError { margin: auto;width: 90%;border: 3px solid red;padding-left: 1em;padding-bottom: 1em;}' }, boot: { cordova: { enable: true, timeout: -1, 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); // 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.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 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 cache_store(id INTEGER PRIMARY KEY AUTOINCREMENT, key TEXT NOT NULL, value TEXT NOT NULL)'; executeSql(tx, query, [], function(tx) { executeSql(tx, 'CREATE UNIQUE INDEX cache_store__key__udx ON cache_store (key)', [], nullDataHandler(cb), cb); }, cb); }); }; return { cacheOpen: function(cb) { if (cacheDB !== null) { return cb(null); // open once. } try { cacheDB = rootWindow.openDatabase(opt.name, opt.version, opt.name, opt.size); } catch(e) { return cb(e); } cacheDB.transaction(function(tx) { executeSql(tx,'SELECT value FROM cache_store WHERE key = \"test-for-table\"', [], function() { cb(null); }, function() { cacheDBInit(cb); }); }); }, cacheGetValue: function(key, cb) { cacheDB.transaction(function(tx) { executeSql(tx, 'SELECT value FROM cache_store 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 cache_store WHERE key = ?',[key], function(tx, res) { if (res.rows.length === 0) { var queryInsert = 'INSERT INTO cache_store (key,value) VALUES (?,?)'; executeSql(tx, queryInsert, [key,JSON.stringify(value)], nullDataHandler(cb), cb); } else { var queryUpdate = 'UPDATE cache_store 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 cache_store WHERE key = ?', [key], function () { setTimeout(nullDataHandler(cb)); // return next tick so transaction is flushed before location.reload }, 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) { 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(); }); var stackText = stack.splice(stack[0] === 'Error' ? 2 : 1); var traceTag = document.createElement('div'); 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 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 utilRunStack = function(runType, stack, step, cb) { if (stack.length === 0) { return cb(null); } utilDebug(runType); 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)); }; 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) { 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); } var diff = []; for (var i=0;i 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 }; });