/** * HTML5 Web Worker and Fetch API based script loader with localStorage cache * * Inspired by basket.js * @link https://addyosmani.com/basket.js/ * * @package abovethefold * @subpackage abovethefold/public * @author Optimization.Team */ Abtf[CONFIG.LOAD_MODULE](function(window, Abtf) { // test availability of localStorage if (!window.localStorage) { return; } // test availability Web Workers if (!window.Worker) { return; } /** * Object urls to revoke on unload */ var OBJECT_URLS = []; // async var ASYNC = function(fn) { if ('Promise' in window) { new Promise(function resolver(resolve, reject) { resolve(fn()); }); } else { if (window.setImmediate !== 'undefined') { window.setImmediate(fn); } else { setTimeout(fn, 0); } } }; /** * localStorage controller */ var LS = { // Prefix for cache entries prefix: 'abtf-', // Default expire time in seconds default_expire: 86400, // 1 day // preloaded scripts, loaded while waiting for dependencies preloaded: {}, // return current time in seconds now: function() { return (+new Date() / 1000); }, // process task when idle execWhenIdle: function(task, timeframe) { if (Abtf[CONFIG.IDLE]) { // shedule for idle time Abtf[CONFIG.IDLE](task, { timeout: timeframe }); } else { task(); } }, /** * Save script to localStorage cache */ saveScript: function(url, scriptData, expire) { // minimize interference with rendering LS.execWhenIdle(function idleTime() { var scriptObj = {}; var now = LS.now(); scriptObj.date = now; scriptObj.expire = now + (expire || LS.default_expire); if (scriptData instanceof Array) { // chunked scriptObj.chunked = true; scriptObj.chunks = scriptData.length; var chunkObjects = []; var l = scriptData.length; for (var i = 0; i < l; i++) { chunkObjects.push(scriptData[i]); } } else { var chunkObjects = false; scriptObj.data = scriptData; } LS.add(url, scriptObj); if (chunkObjects) { var l = chunkObjects.length; for (var i = 0; i < l; i++) { LS.add('chunk:' + i + ':' + url, chunkObjects[i]); } } }, 3000); }, /** * Get script from localStorage cache */ getScript: function(url) { if (typeof LS.preloaded[url] !== 'undefined' && LS.preloaded[url] !== false) { return LS.preloaded[url]; } // abort preloading LS.preloaded[url] = false; // get from localStorage var cacheObject = LS.get(url); if (!cacheObject || typeof cacheObject !== 'object') { return false; // not in cache } // verify expire time if (typeof cacheObject.expire !== 'undefined' && (cacheObject.expire - LS.now()) < 0) { return false; // expired } /** * Chunked data */ if (typeof cacheObject.chunked !== 'undefined' && cacheObject.chunked === true) { var data = [], chunkData; for (var i = 0; i < cacheObject.chunks; i++) { chunkData = LS.get('chunk:' + i + ':' + url); // chunk is missing if (chunkData === false || typeof chunkData === 'undefined') { return false; } data.push(chunkData); } cacheObject.data = data.join(''); } else if (!cacheObject.data) { return false; // no data } var scriptData = '/* @source ' + url + ' */\n'; var idle = false, idle_timeframe; /** requestIdleCallback */ if (Abtf[CONFIG.IDLE] && typeof Abtf[CONFIG.JS][2] !== 'undefined' && Abtf[CONFIG.JS][2]) { var l = Abtf[CONFIG.JS][2].length, str; for (var i = 0; i < l; i++) { if (typeof Abtf[CONFIG.JS][2][i] !== 'object') { continue; } if (url.indexOf(Abtf[CONFIG.JS][2][i][0]) !== -1) { idle = true; if (Abtf[CONFIG.JS][2][i][1]) { idle_timeframe = Abtf[CONFIG.JS][2][i][1]; } break; } } } if (idle) { scriptData += 'window.requestIdleCallback(function(){'; scriptData += cacheObject.data; if (idle_timeframe) { scriptData += '},{timeout:' + idle_timeframe + '});'; } else { scriptData += '});'; } } else { scriptData += cacheObject.data; } // create blob url LS.preloaded[url] = createBlobUrl(scriptData, 'application/javascript'); OBJECT_URLS.push(LS.preloaded[url]); return LS.preloaded[url]; }, /** * Preload script */ preloadScript: function(url) { if (typeof LS.preloaded[url] !== 'undefined') { return; } // minimize interference with rendering LS.execWhenIdle(function idleTime() { if (typeof LS.preloaded[url] !== 'undefined') { return; } LS.preloaded[url] = LS.getScript(url); }, 100); }, /** * Add data to localStorage cache */ add: function(key, storeObj, retryCount) { // skip retry after 10 removed entries if (typeof retryCount !== 'undefined' && parseInt(retryCount) > 10) { if (ABTFDEBUG) { console.error('Abtf.js() ➤ localStorage quota reached', 'retry limit reached, abort saving...', key); } return; } if (typeof storeObj === 'object') { storeObj = JSON.stringify(storeObj); } try { localStorage.setItem(LS.prefix + key, storeObj); return true; } catch (e) { /** * localStorage quota reached, prune old cache entries */ if (e.name.toUpperCase().indexOf('QUOTA') >= 0) { var item, entry, entryKey; var tempScripts = []; for (item in localStorage) { if (item.indexOf(LS.prefix) === 0 && item.indexOf('chunk:') === -1) { entryKey = item.split(LS.prefix)[1]; entry = LS.get(entryKey); if (entry) { tempScripts.push([entryKey, entry]); } } } if (tempScripts.length) { tempScripts.sort(function(a, b) { return a[1].date - b[1].date; }); if (ABTFDEBUG) { console.error('Abtf.js() ➤ localStorage quota reached', 'removed', tempScripts[0][0], 'for key', key); } LS.remove(tempScripts[0][0]); // minimize interference with rendering LS.execWhenIdle(function idleTime() { if (typeof retryCount === 'undefined') { retryCount = 0; } LS.add(key, storeObj, ++retryCount); }, 1000); return; } else { if (ABTFDEBUG) { console.error('Abtf.js() ➤ localStorage quota reached', 'no files to remove'); } // no files to remove. Larger than available quota return; } } else { if (ABTFDEBUG) { console.error('Abtf.js() ➤ localStorage error', e.name, e); } // some other error return; } } }, /** * Remove from localStorage */ remove: function(key) { var entry = LS.get(key); if (!entry) { return; } if (entry.chunked) { // remove chunks var l = parseInt(entry.chunks); for (var i = 0; i < l; i++) { localStorage.removeItem(LS.prefix + 'chunk:' + i + ':' + key); } } localStorage.removeItem(LS.prefix + key); }, /** * Get from localStorage */ get: function(key) { var item = localStorage.getItem(LS.prefix + key); try { // chunk, return string data if (key.indexOf('chunk:') !== -1) { return item || false; } // return entry object return JSON.parse(item || 'false'); } catch (e) { return false; } }, /** * Clear expired entries in localStorage */ clear: function(expired) { var item, key; var now = this.now(); if (ABTFDEBUG) { var removed = []; } var entry, clear; for (item in localStorage) { key = item.split(LS.prefix)[1]; if (key) { if (key.indexOf('chunk:') !== -1) { // chunk, remove by parent object continue; } // get entry entry = LS.get(key); if (!entry) { // entry does not exist continue; } if (!expired || entry.expire <= now) { // remove entry LS.remove(key); if (ABTFDEBUG) { removed.push(key); } } } } if (ABTFDEBUG) { if (removed.length > 0) { console.warn('Abtf.js() ➤ localStorage cleared', removed.length, 'expired scripts'); } } } }; /** * Create javascript blob url */ var createBlobUrl = function(fileData, mimeType) { var blob; /** * Create blob */ try { blob = new Blob([fileData], { type: mimeType }); } catch (e) { // Backwards-compatibility window.BlobBuilder = window.BlobBuilder || window.WebKitBlobBuilder || window.MozBlobBuilder; blob = new BlobBuilder(); blob.append(fileData); blob = blob.getBlob(mimeType); } /** * Return blob url */ return URL.createObjectURL(blob); }; /** * Web Worker source code */ var WORKER_CODE = ((function() { // Fetch API self.FETCH = self.fetch || false; // default timeout self.DEFAULT_TIMEOUT = 5000; // @todo performance tests // @link https://jsperf.com/localstorage-10x100kb-vs-2x-500kb-vs-1x-1mb self.MAX_CHUNK_SIZE = 100000; // 100kb // chunk data for localStorage self.CHUNK_DATA = function(data, chunkSize) { var chunksCount = Math.ceil(data.length / chunkSize); var chunks = new Array(chunksCount); var offset; for (var i = 0; i < chunksCount; i++) { offset = i * chunkSize; chunks[i] = data.substring(offset, offset + chunkSize); } return chunks; }; /** * Method for loading resource */ self.LOAD_RESOURCE = function(file) { // resource loaded flag var resourceLoaded = false; var request_timeout = false; // onload callback var resourceOnload = function(error, returnData) { if (resourceLoaded) { return; // already processed } resourceLoaded = true; if (request_timeout) { clearTimeout(request_timeout); request_timeout = false; } if (!error && returnData) { /** * localStorage appears to become buggy with large scripts * * Split data in chunks. */ var dataSize = returnData.length; // calculate data size if (dataSize > self.MAX_CHUNK_SIZE) { returnData = self.CHUNK_DATA(returnData, self.MAX_CHUNK_SIZE); } } self.RESOURCE_LOAD_COMPLETED(file, error, returnData); }; /** * Use Fetch API */ if (self.FETCH) { // fetch configuration var fetchInit = { method: 'GET', mode: 'cors', cache: 'default' }; var handleError = function(error) { if (resourceLoaded) { return; // already processed } if (typeof error === 'object' && error.status) { error = [error.status, error.statusText]; } // error resourceOnload(error); }; // fetch request self.FETCH(file.url, fetchInit) .then(function(response) { if (resourceLoaded) { return; // already processed } // handle response if (response.ok) { // get text data response.text().then(function(data) { resourceOnload(false, data); }); } else { // error resourceOnload([response.status, response.statusText]); } }, handleError).catch(handleError); // Fetch API does not support abort or cancel or timeout // simply ignore the request on timeout var timeout = file.timeout || self.DEFAULT_TIMEOUT; if (isNaN(timeout)) { timeout = self.DEFAULT_TIMEOUT; } request_timeout = setTimeout(function requestTimeout() { if (resourceLoaded) { return; // already processed } resourceOnload('timeout'); }, timeout); } else { // start XHR request var xhr = new XMLHttpRequest(); xhr.open('GET', file.url, true); /** * Set XHR response type */ xhr.responseType = 'text'; // watch state change xhr.onreadystatechange = function() { if (resourceLoaded) { return; // already processed } // handle response if (xhr.readyState === 4) { if (xhr.status !== 200) { // error resourceOnload(xhr.statusText); } else { /** * Return text */ resourceOnload(false, xhr.responseText); } } } /** * Resource load completed */ xhr.onerror = function resourceError() { if (resourceLoaded) { return; // already processed } resourceOnload(xhr.statusText); }; // By default XHRs never timeout, and even Chrome doesn't implement the // spec for xhr.timeout. So we do it ourselves. var timeout = file.timeout || self.DEFAULT_TIMEOUT; if (isNaN(timeout)) { timeout = self.DEFAULT_TIMEOUT; } request_timeout = setTimeout(function requestTimeout() { if (resourceLoaded) { return; // already processed } try { xhr.abort(); } catch (e) { } resourceOnload('timeout'); }, timeout); xhr.send(null); } }; /** * Post back to UI after completion of specific resource */ self.RESOURCE_LOAD_COMPLETED = function(file, error, returnData) { if (error) { if (!(error instanceof Array) && typeof error === 'object') { error = error.toString(); } // return error self.postMessage([2, file.i, error]); } else { // send back data to save in localStorage self.postMessage([1, file.i, returnData]); } }; /** * Handle load request for web worker */ self.onmessage = function(oEvent) { var files = oEvent.data; // load multiple files if (files instanceof Array) { var l = files.length; for (var i = 0; i < l; i++) { if (typeof files[i] === 'object' && typeof files[i].url !== 'undefined' && typeof files[i].i !== 'undefined') { self.LOAD_RESOURCE(files[i]); } } } else if (typeof files === 'object' && typeof files.url !== 'undefined' && typeof files.i !== 'undefined') { self.LOAD_RESOURCE(files); } else { throw new Error('Web Worker Script Loader: Invalid resource object'); } } }).toString() .replace(/^function\s*\(\s*\)\s*\{/, '') .replace(/\}$/, '') ); /** * Web Worker Script Loader */ var WEBWORKER = { // web worker code workerUri: createBlobUrl(WORKER_CODE, 'application/javascript'), // web worker worker: false, scriptIndex: 0, scriptQueue: [], // start web worker start: function() { this.worker = new Worker(this.workerUri); // listen for messages from worker this.worker.addEventListener('message', this.handleMessage); // listen for errors this.worker.addEventListener('error', this.handleError); }, /** * Stop web worker */ stop: function() { if (this.worker) { // remove listeners this.worker.removeEventListener('message', this.handleMessage); // listen for errors this.worker.removeEventListener('error', this.handleError); // terminate worker this.worker.terminate(); this.worker = false; if (ABTFDEBUG) { console.warn('Abtf.js() ➤ web worker terminated'); } } }, /** * Handle response from Web Worker */ handleMessage: function(event) { var response = event.data; var scriptIndex = response[1]; if (typeof WEBWORKER.scriptQueue[scriptIndex] === 'undefined') { // script not in queue if (ABTFDEBUG) { console.error('Abtf.js() ➤ web worker script loader invalid response', response); } return; } // data is returned if (parseInt(response[0]) === 1) { WEBWORKER.scriptQueue[scriptIndex].onData(response[2]); return; } // error if (parseInt(response[0]) === 2) { if (ABTFDEBUG) { if (response[2] instanceof Array) { if (parseInt(response[2][0]) > 200 && parseInt(response[2][0]) < 600) { console.error('Abtf.js() ➤ web worker ➤ ' + response[2][0] + ' ' + response[2][1], WEBWORKER.scriptQueue[scriptIndex].url); return; } } console.error('Abtf.js() ➤ web worker script loader error', response[2]); } return; } }, /** * Handle error response */ handleError: function(error) { // output error to console if (ABTFDEBUG) { console.error('Abtf.js() ➤ web worker script loader error', error); } }, /** * Load script */ loadScript: function(url, onData) { if (!this.worker) { this.start(); } url = Abtf[CONFIG.PROXIFY](url); var scriptIndex = parseInt(this.scriptIndex); this.scriptIndex++; // add to queue this.scriptQueue[scriptIndex] = { url: url, onData: onData }; // send to web worker this.worker.postMessage({ url: url, i: scriptIndex }); } }; // start web worker WEBWORKER.start(); /** * Clear memory */ window.addEventListener("beforeunload", function(e) { // stop web worker WEBWORKER.stop(); // revoke script object urls if (OBJECT_URLS.length > 0) { var l = OBJECT_URLS.length; for (var i = 0; i < l; i++) { try { URL.revokeObjectURL(OBJECT_URLS[i]); } catch (err) { if (ABTFDEBUG) { console.error('Abtf.js() ➤ failed to revoke script url', OBJECT_URLS[i], err); } } } } }); /** * Clear expired entries */ if (Abtf[CONFIG.IDLE]) { // shedule for idle time Abtf[CONFIG.IDLE](function() { LS.clear(true); }, { timeout: 3000 }); } else { // fallback to setTimeout var clear_timeout; var initClearTimeout = function() { if (clear_timeout) { clearTimeout(clear_timeout); } clear_timeout = setTimeout(function() { LS.clear(true); }, 2000); }; // set timeout initClearTimeout(); // reset timeout on script load Abtf[CONFIG.ON_SCRIPT_LOAD](initClearTimeout); } /** * Load cached script */ Abtf[CONFIG.LOAD_CACHED_SCRIPT] = function(src, callback, onStart) { ASYNC(function() { /** * Try localStorage cache */ var url = LS.getScript(src); if (url) { if (ABTFDEBUG) { onStart(url); } Abtf[CONFIG.LOAD_SCRIPT](url, callback); return; } if (ABTFDEBUG) { // not cached onStart(false); } /** * Not in cache, start regular request and potentially use browser cache speed */ Abtf[CONFIG.LOAD_SCRIPT](src, function scriptLoaded() { callback(); /** * Load script into cache in the background */ WEBWORKER.loadScript(src, function onData(scriptData) { if (!scriptData) { if (ABTFDEBUG) { console.error('Abtf.js() ➤ web worker script loader no data', Abtf[CONFIG.LOCALURL](src)); } return; } if (ABTFDEBUG) { if (scriptData instanceof Array) { console.info('Abtf.js() ➤ web worker ➤ localStorage saved chunked', '(' + scriptData.length + ' chunks)', Abtf[CONFIG.LOCALURL](src)); } else { console.info('Abtf.js() ➤ web worker ➤ localStorage saved', '(' + scriptData.length + ')', Abtf[CONFIG.LOCALURL](src)); } } // save script to local storage LS.saveScript(src, scriptData); }); }); }); }; /** * Preload cached script */ Abtf[CONFIG.PRELOAD_CACHED_SCRIPT] = function(url) { ASYNC(function() { LS.preloadScript(url); }); }; /** * Load cached script url */ Abtf[CONFIG.LOAD_CACHED_SCRIPT_URL] = function(src) { /** * Try localStorage cache */ var url = LS.getScript(src); if (url) { return url; } /** * Load script into cache in the background */ WEBWORKER.loadScript(src, function onData(scriptData) { if (!scriptData) { if (ABTFDEBUG) { console.error('Abtf.js() ➤ web worker script loader no data', Abtf[CONFIG.LOCALURL](src)); } return; } if (ABTFDEBUG) { if (scriptData instanceof Array) { console.info('Abtf.js() ➤ web worker ➤ localStorage saved chunked', '(' + scriptData.length + ' chunks)', Abtf[CONFIG.LOCALURL](src)); } else { console.info('Abtf.js() ➤ web worker ➤ localStorage saved', '(' + scriptData.length + ')', Abtf[CONFIG.LOCALURL](src)); } } // save script to local storage LS.saveScript(src, scriptData); }); // return original url return src; }; });