diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b78295e --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +.DS_Store +node_modules + +# Log files +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Editor directories and files +.idea +.vscode +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/README.md b/README.md index 7e33964..9d9741b 100644 --- a/README.md +++ b/README.md @@ -4,32 +4,38 @@ indexed-cache is a tiny Javascript library that "sideloads" static assets (script, link, and img tags) on webpages using the fetch() API and caches them in an IndexedDB store to eliminate the dependency on the standard browser static asset cache, and to eliminate HTTP requests on subsequent page loads. Javascript, CSS, and image assets are stored in IndexedDB as Blob()s. -### For very specific scenarios only! +### For very specific scenarios only + This library is only meant to be used in very specific scenarios. Unlike the browser's asset cache, IndexedDB is not cleared automatically, providing a longer term static file storage on the client side. The lib uses ES6 (and IndexedDB) and is only expected to work on recent versions of modern browsers. Ideally, this should have been handled with ServiceWorkers, but they don't work in mobile webviews. Use if at least a few of these are true: + - There are large static files (JS, CSS) that rarely change. - High traffic from a large number of returning users who access web pages with the same assets regularly and frequently. - The pages are mostly inside mobile webviews where browser cache gets evicted (OS pressure) causing the same assets to be fetched afresh over and over wasting bandwidth. - Bandwidth is a concern. ### Features + - Supports script, img, link tags. - Respects `defer / async` on script tags. - Can invalidate cached items with a TTL per tag. - Can invalidate cached items with a simple random hash per tag. ### Gotchas + - CORS. - First-paint "flash" (needs to be handled manually) as scripts and styles only load after HTML is fetched and rendered by the browser. - Browser compatibility. -- Empty space or line breaks between the opening and closing `` tags will be executed as an inline script by the browser, after which the browser will not load the remote script when applied. Ensure that the opening and closing script ags have nothing between then. +- Empty space or line breaks between the opening and closing `` tags will be executed as an inline script by the browser, after which the browser will not load the remote script when applied. Ensure that the opening and closing script tags have nothing between then. +- Scripts that rely on the `document.onload` event will need the event to be triggered for them manually once indexed-cache loads with a `document.dispatchEvent(new Event("load"));` ## Usage To cache and sideload static assets: + - Change the original `src` (`href` for CSS) attribute on tags to `data-src`. - Give tags a unique ID with `data-key`. The cached items are stored in the database with this key. The actual filenames of the assets can change freely, like in the case of JS build systems. - Load and invoke indexed-cache at the end. @@ -73,16 +79,59 @@ To cache and sideload static assets: Always include and invoke indexed-cache at the end, right before . Use the unpkg CDN or download and host the script locally (dist/indexed-cache.min.js). !--> - - + + + + + + ``` +#### Load modern and legacy bundle conditionally + +Here is an example on how to load modern(ESM) bundle and legacy bundle conditionally based on browser support. + +```html + + + + + + +``` #### Optional configuration -One or more of these optional params can be passed during initialization. Default values are shown below. +One or more of these optional params can be passed during initialization. Default values are shown below. ```javascript new IndexedCache({ @@ -97,12 +146,18 @@ new IndexedCache({ // certain pages and some on other. prune: false, + // Enabling this skips IndexedDB caching entirely, + // causing resources to be fetched over HTTP every time. + // Useful in dev environments. + skip: false, + // Default expiry for an object in minutes (default 3 months). // Set to null for no expiry. expiry: 131400 }).load(); ``` -To manually prune all objects in the database except for a given list of keys, after `await init()`, call `.prune([list of keys])`. +- `load()` can be called with a DOM Node or NodeList. When none are given, it scans the entire DOM. +- To manually prune all objects in the database except for a given list of keys, after `await init()`, call `.prune([list of keys])`. Licensed under the MIT license. diff --git a/babel.config.js b/babel.config.js deleted file mode 100644 index e8c1493..0000000 --- a/babel.config.js +++ /dev/null @@ -1,12 +0,0 @@ -module.exports = { - presets: [ - [ - '@babel/preset-env', - { - targets: { - node: '6.5' - } - } - ] - ] -} diff --git a/dist/indexed-cache.esm.js b/dist/indexed-cache.esm.js deleted file mode 100644 index 4d11119..0000000 --- a/dist/indexed-cache.esm.js +++ /dev/null @@ -1,306 +0,0 @@ -let _icLoaded = false; -class IndexedCache { - constructor (options) { - if (_icLoaded) { - throw new Error('indexed-cache is already loaded') - } - _icLoaded = true; - - this.opt = { - tags: ['script', 'img', 'link'], - dbName: 'indexed-cache', - storeName: 'objects', - - // If this is enabled, all objects in the cash with keys not - // found on elements on the page (data-key) will be deleted by load(). - // This can be problematic in scenarios where there are multiple - // pages on the same domain that have different assets, some on - // certain pages and some on other. - prune: false, - - // Default expiry for an object in minutes (default 3 months). - // Set to null for no expiry. - expiry: 131400, - - ...options - }; - this.db = null; - } - - // This should be called before calling any other methods. - async init () { - if (this.db) { - return - } - - await this._initDB(this.opt.dbName, this.opt.storeName).then((db) => { - this.db = db; - }).catch((e) => { - console.log('error initializing cache DB. failing over.', e); - }); - } - - // Initialize the DB and then scan and setup DOM elements to cache. - async load () { - // This will setup the elements on the page irrespective of whether - // the DB is available or not. - let objs = []; - await this._setupElements().then((_objs) => { - objs = _objs; - }); - - if (!this.db || objs.length === 0) { - return - } - - // If pruning is enabled, delete all cached elements that are no longer - // referenced on the page. - if (this.opt.prune) { - // Pass the list of keys found on the page. - const keys = objs.reduce((obj, v) => { obj.push(v.key); return obj }, []); - this._prune(keys); - } - } - - deleteKey (key) { - this._store().delete(key); - } - - // Prune all objects in the DB that are not in the given list of keys. - prune (keys) { - this._prune(keys); - } - - clear () { - this._store().clear(); - } - - // Initialize the indexedDB database and create the store. - async _initDB (dbName, storeName) { - return new Promise((resolve, reject) => { - if (!window.indexedDB) { - reject(new Error('indexedDB is not available')); - } - - const req = window.indexedDB.open(dbName); - - // Setup the DB schema for the first time. - req.onupgradeneeded = (e) => { - const db = e.target.result; - if (!e.target.result.objectStoreNames.contains(storeName)) { - db.createObjectStore(storeName, { keyPath: 'key' }); - e.target.transaction.oncomplete = () => { - resolve(db); - }; - } - }; - - req.onsuccess = () => resolve(req.result); - - req.onerror = (e) => reject(e.target.error); - }) - } - - // Scan all matching elements and either: - // a) if indexedDB is not available, fallback to loading the assets natively. - // b) if DB is available but the object is not cached, fetch(), cache in B, and apply the blob. - // c) if DB is available and the object is cached, apply the cached blob. - - async _setupElements () { - const objs = []; - - // Get all tags of a particular tag on the page that has the data-src attrib. - this.opt.tags.forEach((tag) => { - document.querySelectorAll(`${tag}[data-src]:not([data-indexed])`).forEach((el) => { - const obj = { - el: el, - key: el.dataset.key || el.dataset.src, - src: el.dataset.src, - hash: el.dataset.hash || el.dataset.src, - isAsync: el.tagName !== 'SCRIPT' || el.hasAttribute('async') || el.hasAttribute('defer'), - expiry: null - }; - - // If there is a global expiry or an expiry on the object, compute that. - const exp = el.dataset.expiry || this.opt.expiry; - if (exp) { - obj.expiry = new Date(new Date().getTime() + (parseInt(exp) * 60000)); - } - - // If for any reason the store is not initialized, fall back to - // the native asset loading mechanism. - if (this.db) { - objs.push(obj); - } else { - this._applyOriginal(obj); - } - }); - }); - - if (objs.length === 0) { - return objs - } - - const promises = []; - objs.forEach((obj) => { - if (obj.isAsync) { - // Load and apply async objects asynchronously. - this._loadObject(obj).then((result) => { - this._applyBlob(obj, result.data.blob); - }).catch((e) => { - this._applyOriginal(obj); - }); - } else { - // Load non-async objects asynchronously (but apply synchronously). - promises.push(this._loadObject(obj)); - } - }); - - // Once the assets have been fetched, apply them synchronously. Since - // the time take to execute a script is not guaranteed, use the onload() event - // of each element to load the next element. - await Promise.all(promises).then((results) => { - results.forEach((r, n) => { - if (n >= results.length - 1) { - return - } - - r.obj.el.onload = () => { - this._applyBlob(results[n + 1].obj, results[n + 1].data.blob); - }; - }); - - // Start the chain by loading the first element. - this._applyBlob(results[0].obj, results[0].data.blob); - }); - - return objs - } - - async _loadObject (obj) { - return new Promise((resolve, reject) => { - // Get the stored blob. - this._getBlob(obj).then((data) => { - resolve({ obj, data }); - }).catch((e) => { - // If there is no cause, the object is not cached or has expired. - if (e.toString() === '') { - console.log('error getting cache blob:', e); - } - - // Couldn't get the stored blog. Attempt to fetch() and cache. - this._fetchAsset(obj).then((data) => { - resolve({ obj, data }); - }).catch((e) => { - // Everything failed. Failover to loading assets natively. - reject(new Error('error fetching asset: ' + e)); - }); - }); - }) - } - - // Get the blob of an asset stored in the DB. If there is no entry or it has expired - // (hash changed or date expired), fetch the asset over HTTP, cache it, and load it. - async _getBlob (obj) { - return new Promise((resolve, reject) => { - const req = this._store().get(obj.key); - req.onsuccess = (e) => { - const data = e.target.result; - - // Reject if there is no stored data, or if the hash has changed. - if (!data || (obj.hash && (data.hash !== obj.hash))) { - reject(new Error('')); - return - } - - // Reject and delete if the object has expired. - if (data.expiry && new Date() > new Date(data.expiry)) { - this.deleteKey(data.key); - reject(new Error('')); - return - } - - resolve(data); - }; - - req.onerror = (e) => { - reject(e.target.error); - }; - }) - } - - // Fetch an asset and cache it. - async _fetchAsset (obj) { - return new Promise((resolve, reject) => { - fetch(obj.src).then((r) => { - if (!r.ok) { - reject(new Error(`error fetching asset: ${r.status}`)); - return - } - - r.blob().then((b) => { - const data = { - key: obj.key, - hash: obj.hash, - expiry: obj.expiry, - blob: b - }; - - const req = this._store().put(data); - req.onsuccess = (e) => resolve(data); - req.onerror = (e) => reject(e.target.error); - }); - }).catch((e) => { - reject(new Error(e.toString())); - }); - }) - } - - // Fallback (because there is no DB) to loading the assets via the native mechanism. - _applyOriginal (obj) { - switch (obj.el.tagName) { - case 'SCRIPT': - case 'IMG': - obj.el.setAttribute('src', obj.src); - break - case 'LINK': - obj.el.setAttribute('href', obj.src); - } - obj.el.dataset.indexed = true; - } - - // Apply the Blob() to the given element. - _applyBlob (obj, blob) { - const b = window.URL.createObjectURL(blob); - switch (obj.el.tagName) { - case 'SCRIPT': - case 'IMG': - obj.el.src = b; - break - case 'LINK': - obj.el.href = b; - } - obj.el.dataset.indexed = true; - } - - // Delete all objects in cache that are not in the given list of objects. - _prune (keys) { - // Prepare a { key: true } lookup map of all keys found on the page. - const keyMap = keys.reduce((obj, v) => { obj[v] = true; return obj }, {}); - - const req = this._store().getAllKeys(); - req.onsuccess = (e) => { - e.target.result.forEach((key) => { - if (!(key in keyMap)) { - this.deleteKey(key); - } - }); - }; - } - - _store () { - return this.db.transaction(this.opt.storeName, 'readwrite').objectStore(this.opt.storeName) - } -} - -export { IndexedCache as default }; diff --git a/dist/indexed-cache.esm.min.js b/dist/indexed-cache.esm.min.js new file mode 100644 index 0000000..220a9e2 --- /dev/null +++ b/dist/indexed-cache.esm.min.js @@ -0,0 +1,3 @@ +/* indexed-cache - v0.4.4 +* Kailash Nadh. Licensed MIT */ +let e=!1;class t{constructor(t){if(e)throw new Error("indexed-cache is already loaded");e=!0,this.opt={tags:["script","img","link"],dbName:"indexed-cache",storeName:"objects",prune:!1,skip:!1,expiry:131400,...t},this.db=null}async init(){this.db||this.opt.skip||await this._initDB(this.opt.dbName,this.opt.storeName).then((e=>{this.db=e})).catch((e=>{console.log("error initializing cache DB. failing over.",e)}))}async load(e){const t=await this._setupElements(e);if(this.db&&0!==t.length&&this.opt.prune){const e=t.map((e=>e.key));this._prune(e)}}deleteKey(e){this.db&&this._store().delete(e)}prune(e){this._prune(e)}clear(){this.db&&this._store().clear()}_initDB(e,t){return new Promise(((r,s)=>{window.indexedDB||s(new Error("indexedDB is not available"));const a=window.indexedDB.open(e);a.onupgradeneeded=e=>{const s=e.target.result;e.target.result.objectStoreNames.contains(t)||(s.createObjectStore(t,{keyPath:"key"}),e.target.transaction.oncomplete=()=>{r(s)})},a.onsuccess=()=>r(a.result),a.onerror=e=>s(e.target.error),setTimeout((()=>{this.db||s(new Error("Opening IndexedbDB timed out"))}),200)}))}async _setupElements(e){const t=[];if(e instanceof NodeList)e=Array.from(e);else if(e instanceof Node)e=[e];else{const t=this.opt.tags.map((e=>`${e}[data-src]:not([data-indexed])`)).join(",");e=document.querySelectorAll(t)}if(Array.prototype.forEach.call(e,(e=>{if("indexed"in e.dataset)return;const r={el:e,key:e.dataset.key||e.dataset.src,src:e.dataset.src,hash:e.dataset.hash||e.dataset.src,isAsync:"SCRIPT"!==e.tagName||e.hasAttribute("async")||e.hasAttribute("defer"),expiry:null,data:{}},s=e.dataset.expiry||this.opt.expiry;s&&(r.expiry=new Date((new Date).getTime()+6e4*parseInt(s))),t.push(r)})),!this.db)return void this._applyElements(t);const r=[];return t.forEach((e=>{e.isAsync?this._getObject(e).then((t=>{this._applyElement(e,t.data.blob)})).catch((t=>{this._applyElement(e)})):r.push(this._getObject(e))})),0===r.length||await function(e){const t=e.map((e=>Promise.resolve(e).then((e=>({status:"fulfilled",value:e})),(e=>({status:"rejected",reason:e})))));return Promise.all(t)}(r).then((e=>{const t=e.reduce(((e,t)=>(e.push({...t.value.obj,data:t.value.data}),e)),[]);this._applyElements(t)})),t}_getObject(e){return new Promise(((t,r)=>{this._getDBblob(e).then((r=>{t({obj:e,data:r})})).catch((r=>{"Error"!==r.toString()&&console.log("error getting cache blob:",r),this._fetchObject(e).then((r=>{t({obj:e,data:r})})).catch((r=>{t({obj:e,data:{key:e.key,hash:e.hash,expiry:e.expiry,blob:null}})}))}))}))}_getDBblob(e){return new Promise(((t,r)=>{try{const s=this._store().get(e.key);s.onsuccess=s=>{const a=s.target.result;if(a&&(!e.hash||a.hash===e.hash))return a.expiry&&new Date>new Date(a.expiry)?(this.deleteKey(a.key),void r(new Error(""))):void t(a);r(new Error(""))},s.onerror=e=>{r(e.target.error)}}catch(e){r(e.target.error)}}))}_fetchObject(e){return new Promise(((t,r)=>{fetch(e.src).then((s=>{s.ok?s.blob().then((s=>{const a={key:e.key,hash:e.hash,expiry:e.expiry,blob:s};try{const e=this._store().put(a);e.onsuccess=()=>t(a),e.onerror=e=>r(e.target.error)}catch(e){r(e)}})):r(new Error(`error fetching asset: ${s.status}`))})).catch((e=>r(e)))}))}_applyElement(e,t){let r=e.src;switch(t&&(r=window.URL.createObjectURL(t)),e.el.tagName){case"SCRIPT":case"IMG":e.el.src=r;break;case"LINK":e.el.href=r}e.el.dataset.indexed=!0}_applyElements(e){e.forEach(((t,r)=>{r>=e.length-1||(t.el.onload=t.el.onerror=()=>{this._applyElement(e[r+1],e[r+1].data.blob)})})),this._applyElement(e[0],e[0].data.blob)}_prune(e){if(!this.db)return;const t=e.reduce(((e,t)=>(e[t]=!0,e)),{});this._store().getAllKeys().onsuccess=e=>{e.target.result.forEach((e=>{e in t||this.deleteKey(e)}))}}_store(){return this.db.transaction(this.opt.storeName,"readwrite").objectStore(this.opt.storeName)}}export{t as default}; diff --git a/dist/indexed-cache.legacy.min.js b/dist/indexed-cache.legacy.min.js new file mode 100644 index 0000000..c8acf29 --- /dev/null +++ b/dist/indexed-cache.legacy.min.js @@ -0,0 +1,5 @@ +/* indexed-cache - v0.4.4 +* Kailash Nadh. Licensed MIT */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e(require("core-js/modules/es.array.map.js"),require("core-js/modules/es.object.to-string.js"),require("core-js/modules/es.promise.js"),require("core-js/modules/es.array.from.js"),require("core-js/modules/es.string.iterator.js"),require("core-js/modules/es.parse-int.js"),require("core-js/modules/es.array.reduce.js"),require("core-js/modules/web.dom-collections.for-each.js"),require("core-js/modules/es.regexp.to-string.js"),require("core-js/modules/es.array.iterator.js"),require("core-js/modules/web.dom-collections.iterator.js"),require("core-js/modules/web.url.js"),require("core-js/modules/web.url-search-params.js"),require("core-js/modules/es.array.index-of.js"),require("core-js/modules/es.array.filter.js")):"function"==typeof define&&define.amd?define(["core-js/modules/es.array.map.js","core-js/modules/es.object.to-string.js","core-js/modules/es.promise.js","core-js/modules/es.array.from.js","core-js/modules/es.string.iterator.js","core-js/modules/es.parse-int.js","core-js/modules/es.array.reduce.js","core-js/modules/web.dom-collections.for-each.js","core-js/modules/es.regexp.to-string.js","core-js/modules/es.array.iterator.js","core-js/modules/web.dom-collections.iterator.js","core-js/modules/web.url.js","core-js/modules/web.url-search-params.js","core-js/modules/es.array.index-of.js","core-js/modules/es.array.filter.js"],e):(t="undefined"!=typeof globalThis?globalThis:t||self).IndexedCache=e()}(this,(function(){"use strict";function t(t,e){var r=Object.keys(t);if(Object.getOwnPropertySymbols){var n=Object.getOwnPropertySymbols(t);e&&(n=n.filter((function(e){return Object.getOwnPropertyDescriptor(t,e).enumerable}))),r.push.apply(r,n)}return r}function r(e){for(var r=1;r=0;--i){var o=this.tryEntries[i],s=o.completion;if("root"===o.tryLoc)return n("end");if(o.tryLoc<=this.prev){var a=r.call(o,"catchLoc"),c=r.call(o,"finallyLoc");if(a&&c){if(this.prev=0;--n){var i=this.tryEntries[n];if(i.tryLoc<=this.prev&&r.call(i,"finallyLoc")&&this.prev=0;--e){var r=this.tryEntries[e];if(r.finallyLoc===t)return this.complete(r.completion,r.afterLoc),_(r),h}},catch:function(t){for(var e=this.tryEntries.length-1;e>=0;--e){var r=this.tryEntries[e];if(r.tryLoc===t){var n=r.completion;if("throw"===n.type){var i=n.arg;_(r)}return i}}throw new Error("illegal catch attempt")},delegateYield:function(t,e,r){return this.delegate={iterator:E(t),resultName:e,nextLoc:r},"next"===this.method&&(this.arg=void 0),h}},t}function i(t,e,r,n,i,o,s){try{var a=t[o](s),c=a.value}catch(t){return void r(t)}a.done?e(c):Promise.resolve(c).then(n,i)}function o(t){return function(){var e=this,r=arguments;return new Promise((function(n,o){var s=t.apply(e,r);function a(t){i(s,n,o,a,c,"next",t)}function c(t){i(s,n,o,a,c,"throw",t)}a(void 0)}))}}function s(t,e){for(var r=0;r=0||navigator.userAgent.indexOf("Safari/603")>=0)&&(a=!0),(a||void 0===i.prototype.getAll||void 0===n.prototype.getAll||void 0===i.prototype.getAllKeys||void 0===n.prototype.getAllKeys)&&(o=function(){this.result=null,this.error=null,this.source=null,this.transaction=null,this.readyState="pending",this.onsuccess=null,this.onerror=null,this.toString=function(){return"[object IDBRequest]"},this._listeners={success:[],error:[]};var t=this;this.addEventListener=function(e,r){t._listeners[e]&&t._listeners[e].push(r)},this.removeEventListener=function(e,r){t._listeners[e]&&(t._listeners[e]=t._listeners[e].filter((function(t){return r!==t})))}},r=function(t){this.type=t,this.target=null,this.currentTarget=null,this.NONE=0,this.CAPTURING_PHASE=1,this.AT_TARGET=2,this.BUBBLING_PHASE=3,this.eventPhase=this.NONE,this.stopPropagation=function(){console.log("stopPropagation not implemented in IndexedDB-getAll-shim")},this.stopImmediatePropagation=function(){console.log("stopImmediatePropagation not implemented in IndexedDB-getAll-shim")},this.bubbles=!1,this.cancelable=!1,this.preventDefault=function(){console.log("preventDefault not implemented in IndexedDB-getAll-shim")},this.defaultPrevented=!1,this.isTrusted=!1,this.timestamp=Date.now()},s=function(t,n){return function(i,s){var a,c,u;return i=void 0!==i?i:null,c=new o,u=[],(a=this.openCursor(i)).onsuccess=function(e){var i,o,a,l;if((i=e.target.result)&&(l="value"===n?i.value:"index"===t?i.primaryKey:i.key,u.push(l),void 0===s||u.length0)for(a=0;a0)for(r=0;rnew Date(o.expiry)?(this.deleteKey(o.key),void n(new Error(""))):void r(o);n(new Error(""))}.bind(this),o.onerror=function(t){c(this,i),n(t.target.error)}.bind(this)}catch(t){n(t.target.error)}}.bind(this))}},{key:"_fetchObject",value:function(t){var e=this;return new Promise(function(r,n){var i=this;c(this,e),fetch(t.src).then(function(e){var o=this;c(this,i),e.ok?e.blob().then(function(e){var i=this;c(this,o);var s={key:t.key,hash:t.hash,expiry:t.expiry,blob:e};try{var a=this._store().put(s);a.onsuccess=function(){return c(this,i),r(s)}.bind(this),a.onerror=function(t){return c(this,i),n(t.target.error)}.bind(this)}catch(t){n(t)}}.bind(this)):n(new Error("error fetching asset: ".concat(e.status)))}.bind(this)).catch(function(t){return c(this,i),n(t)}.bind(this))}.bind(this))}},{key:"_applyElement",value:function(t,e){var r=t.src;switch(e&&(r=window.URL.createObjectURL(e)),t.el.tagName){case"SCRIPT":case"IMG":t.el.src=r;break;case"LINK":t.el.href=r}t.el.dataset.indexed=!0}},{key:"_applyElements",value:function(t){var e=this;t.forEach(function(r,n){var i=this;c(this,e),n>=t.length-1||(r.el.onload=r.el.onerror=function(){c(this,i),this._applyElement(t[n+1],t[n+1].data.blob)}.bind(this))}.bind(this)),this._applyElement(t[0],t[0].data.blob)}},{key:"_prune",value:function(t){var e=this;if(this.db){var r=t.reduce(function(t,r){return c(this,e),t[r]=!0,t}.bind(this),{});this._store().getAllKeys().onsuccess=function(t){var n=this;c(this,e),t.target.result.forEach(function(t){c(this,n),t in r||this.deleteKey(t)}.bind(this))}.bind(this)}}},{key:"_store",value:function(){return this.db.transaction(this.opt.storeName,"readwrite").objectStore(this.opt.storeName)}}],i&&s(e.prototype,i),a&&s(e,a),Object.defineProperty(e,"prototype",{writable:!1}),t}();return f})); diff --git a/dist/indexed-cache.min.js b/dist/indexed-cache.min.js index b8f2209..f1c122d 100644 --- a/dist/indexed-cache.min.js +++ b/dist/indexed-cache.min.js @@ -1,3 +1,3 @@ -/* indexed-cache - v0.3.2 +/* indexed-cache - v0.4.4 * Kailash Nadh. Licensed MIT */ -!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).IndexedCache=t()}(this,(function(){"use strict";function e(e,t){var r=Object.keys(e);if(Object.getOwnPropertySymbols){var n=Object.getOwnPropertySymbols(e);t&&(n=n.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),r.push.apply(r,n)}return r}function t(e,t,r,n,o,a,s){try{var i=e[a](s),c=i.value}catch(e){return void r(e)}i.done?t(c):Promise.resolve(c).then(n,o)}function r(e){return function(){var r=this,n=arguments;return new Promise((function(o,a){var s=e.apply(r,n);function i(e){t(s,o,a,i,c,"next",e)}function c(e){t(s,o,a,i,c,"throw",e)}i(void 0)}))}}function n(e,t,r){return t in e?Object.defineProperty(e,t,{value:r,enumerable:!0,configurable:!0,writable:!0}):e[t]=r,e}let o=!1;return class{constructor(t){if(o)throw new Error("indexed-cache is already loaded");o=!0,this.opt=function(t){for(var r=1;r{e.db=t})).catch((e=>{console.log("error initializing cache DB. failing over.",e)})))}))()}load(){var e=this;return r((function*(){let t=[];if(yield e._setupElements().then((e=>{t=e})),e.db&&0!==t.length&&e.opt.prune){const r=t.reduce(((e,t)=>(e.push(t.key),e)),[]);e._prune(r)}}))()}deleteKey(e){this._store().delete(e)}prune(e){this._prune(e)}clear(){this._store().clear()}_initDB(e,t){return r((function*(){return new Promise(((r,n)=>{window.indexedDB||n(new Error("indexedDB is not available"));const o=window.indexedDB.open(e);o.onupgradeneeded=e=>{const n=e.target.result;e.target.result.objectStoreNames.contains(t)||(n.createObjectStore(t,{keyPath:"key"}),e.target.transaction.oncomplete=()=>{r(n)})},o.onsuccess=()=>r(o.result),o.onerror=e=>n(e.target.error)}))}))()}_setupElements(){var e=this;return r((function*(){const t=[];if(e.opt.tags.forEach((r=>{document.querySelectorAll(`${r}[data-src]:not([data-indexed])`).forEach((r=>{const n={el:r,key:r.dataset.key||r.dataset.src,src:r.dataset.src,hash:r.dataset.hash||r.dataset.src,isAsync:"SCRIPT"!==r.tagName||r.hasAttribute("async")||r.hasAttribute("defer"),expiry:null},o=r.dataset.expiry||e.opt.expiry;o&&(n.expiry=new Date((new Date).getTime()+6e4*parseInt(o))),e.db?t.push(n):e._applyOriginal(n)}))})),0===t.length)return t;const r=[];return t.forEach((t=>{t.isAsync?e._loadObject(t).then((r=>{e._applyBlob(t,r.data.blob)})).catch((r=>{e._applyOriginal(t)})):r.push(e._loadObject(t))})),yield Promise.all(r).then((t=>{t.forEach(((r,n)=>{n>=t.length-1||(r.obj.el.onload=()=>{e._applyBlob(t[n+1].obj,t[n+1].data.blob)})})),e._applyBlob(t[0].obj,t[0].data.blob)})),t}))()}_loadObject(e){var t=this;return r((function*(){return new Promise(((r,n)=>{t._getBlob(e).then((t=>{r({obj:e,data:t})})).catch((o=>{""===o.toString()&&console.log("error getting cache blob:",o),t._fetchAsset(e).then((t=>{r({obj:e,data:t})})).catch((e=>{n(new Error("error fetching asset: "+e))}))}))}))}))()}_getBlob(e){var t=this;return r((function*(){return new Promise(((r,n)=>{const o=t._store().get(e.key);o.onsuccess=o=>{const a=o.target.result;if(a&&(!e.hash||a.hash===e.hash))return a.expiry&&new Date>new Date(a.expiry)?(t.deleteKey(a.key),void n(new Error(""))):void r(a);n(new Error(""))},o.onerror=e=>{n(e.target.error)}}))}))()}_fetchAsset(e){var t=this;return r((function*(){return new Promise(((r,n)=>{fetch(e.src).then((o=>{o.ok?o.blob().then((o=>{const a={key:e.key,hash:e.hash,expiry:e.expiry,blob:o},s=t._store().put(a);s.onsuccess=e=>r(a),s.onerror=e=>n(e.target.error)})):n(new Error(`error fetching asset: ${o.status}`))})).catch((e=>{n(new Error(e.toString()))}))}))}))()}_applyOriginal(e){switch(e.el.tagName){case"SCRIPT":case"IMG":e.el.setAttribute("src",e.src);break;case"LINK":e.el.setAttribute("href",e.src)}e.el.dataset.indexed=!0}_applyBlob(e,t){const r=window.URL.createObjectURL(t);switch(e.el.tagName){case"SCRIPT":case"IMG":e.el.src=r;break;case"LINK":e.el.href=r}e.el.dataset.indexed=!0}_prune(e){const t=e.reduce(((e,t)=>(e[t]=!0,e)),{});this._store().getAllKeys().onsuccess=e=>{e.target.result.forEach((e=>{e in t||this.deleteKey(e)}))}}_store(){return this.db.transaction(this.opt.storeName,"readwrite").objectStore(this.opt.storeName)}}})); +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).IndexedCache=t()}(this,(function(){"use strict";function e(e,t){var r=Object.keys(e);if(Object.getOwnPropertySymbols){var n=Object.getOwnPropertySymbols(e);t&&(n=n.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),r.push.apply(r,n)}return r}function t(t){for(var r=1;r{e.db=t})).catch((e=>{console.log("error initializing cache DB. failing over.",e)})))}))()}load(e){var t=this;return n((function*(){const r=yield t._setupElements(e);if(t.db&&0!==r.length&&t.opt.prune){const e=r.map((e=>e.key));t._prune(e)}}))()}deleteKey(e){this.db&&this._store().delete(e)}prune(e){this._prune(e)}clear(){this.db&&this._store().clear()}_initDB(e,t){return new Promise(((r,n)=>{window.indexedDB||n(new Error("indexedDB is not available"));const o=window.indexedDB.open(e);o.onupgradeneeded=e=>{const n=e.target.result;e.target.result.objectStoreNames.contains(t)||(n.createObjectStore(t,{keyPath:"key"}),e.target.transaction.oncomplete=()=>{r(n)})},o.onsuccess=()=>r(o.result),o.onerror=e=>n(e.target.error),setTimeout((()=>{this.db||n(new Error("Opening IndexedbDB timed out"))}),200)}))}_setupElements(e){var r=this;return n((function*(){const n=[];if(e instanceof NodeList)e=Array.from(e);else if(e instanceof Node)e=[e];else{const t=r.opt.tags.map((e=>`${e}[data-src]:not([data-indexed])`)).join(",");e=document.querySelectorAll(t)}if(Array.prototype.forEach.call(e,(e=>{if("indexed"in e.dataset)return;const t={el:e,key:e.dataset.key||e.dataset.src,src:e.dataset.src,hash:e.dataset.hash||e.dataset.src,isAsync:"SCRIPT"!==e.tagName||e.hasAttribute("async")||e.hasAttribute("defer"),expiry:null,data:{}},o=e.dataset.expiry||r.opt.expiry;o&&(t.expiry=new Date((new Date).getTime()+6e4*parseInt(o))),n.push(t)})),!r.db)return void r._applyElements(n);const o=[];return n.forEach((e=>{e.isAsync?r._getObject(e).then((t=>{r._applyElement(e,t.data.blob)})).catch((t=>{r._applyElement(e)})):o.push(r._getObject(e))})),0===o.length||(yield function(e){const t=e.map((e=>Promise.resolve(e).then((e=>({status:"fulfilled",value:e})),(e=>({status:"rejected",reason:e})))));return Promise.all(t)}(o).then((e=>{const n=e.reduce(((e,r)=>(e.push(t(t({},r.value.obj),{},{data:r.value.data})),e)),[]);r._applyElements(n)}))),n}))()}_getObject(e){return new Promise(((t,r)=>{this._getDBblob(e).then((r=>{t({obj:e,data:r})})).catch((r=>{"Error"!==r.toString()&&console.log("error getting cache blob:",r),this._fetchObject(e).then((r=>{t({obj:e,data:r})})).catch((r=>{t({obj:e,data:{key:e.key,hash:e.hash,expiry:e.expiry,blob:null}})}))}))}))}_getDBblob(e){return new Promise(((t,r)=>{try{const n=this._store().get(e.key);n.onsuccess=n=>{const o=n.target.result;if(o&&(!e.hash||o.hash===e.hash))return o.expiry&&new Date>new Date(o.expiry)?(this.deleteKey(o.key),void r(new Error(""))):void t(o);r(new Error(""))},n.onerror=e=>{r(e.target.error)}}catch(e){r(e.target.error)}}))}_fetchObject(e){return new Promise(((t,r)=>{fetch(e.src).then((n=>{n.ok?n.blob().then((n=>{const o={key:e.key,hash:e.hash,expiry:e.expiry,blob:n};try{const e=this._store().put(o);e.onsuccess=()=>t(o),e.onerror=e=>r(e.target.error)}catch(e){r(e)}})):r(new Error(`error fetching asset: ${n.status}`))})).catch((e=>r(e)))}))}_applyElement(e,t){let r=e.src;switch(t&&(r=window.URL.createObjectURL(t)),e.el.tagName){case"SCRIPT":case"IMG":e.el.src=r;break;case"LINK":e.el.href=r}e.el.dataset.indexed=!0}_applyElements(e){e.forEach(((t,r)=>{r>=e.length-1||(t.el.onload=t.el.onerror=()=>{this._applyElement(e[r+1],e[r+1].data.blob)})})),this._applyElement(e[0],e[0].data.blob)}_prune(e){if(!this.db)return;const t=e.reduce(((e,t)=>(e[t]=!0,e)),{});this._store().getAllKeys().onsuccess=e=>{e.target.result.forEach((e=>{e in t||this.deleteKey(e)}))}}_store(){return this.db.transaction(this.opt.storeName,"readwrite").objectStore(this.opt.storeName)}}})); diff --git a/package.json b/package.json index 3b2e9bb..d01fa94 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@knadh/indexed-cache", - "version": "0.3.2", + "version": "0.4.4", "description": "Sideload static assets on pages and cache them in IndexedDB ensuring longer-term storage.", "main": "src/index.js", "files": [ @@ -32,7 +32,8 @@ "access": "public" }, "browser": "dist/indexed-cache.min.js", - "esm": "dist/indexed-cache.esm.js", + "browserLegacy": "dist/indexed-cache.legacy.min.js", + "esm": "dist/indexed-cache.esm.min.js", "devDependencies": { "@babel/core": "^7.14.6", "@babel/preset-env": "^7.14.7", @@ -40,6 +41,7 @@ "@rollup/plugin-commonjs": "^19.0.0", "@rollup/plugin-eslint": "^8.0.1", "@rollup/plugin-node-resolve": "^13.0.0", + "@rollup/plugin-replace": "^3.0.0", "eslint-config-standard": "^16.0.3", "eslint-plugin-import": "^2.23.4", "eslint-plugin-node": "^11.1.0", diff --git a/rollup.config.js b/rollup.config.js index 7aa11b0..9af807a 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -4,15 +4,18 @@ import commonjs from '@rollup/plugin-commonjs' import pkg from './package.json' import { terser } from 'rollup-plugin-terser' import eslint from '@rollup/plugin-eslint' +import replace from '@rollup/plugin-replace' + const production = !process.env.ROLLUP_WATCH const preamble = `/* indexed-cache - v${pkg.version} * ${pkg.author}. Licensed ${pkg.license} */` export default [ + // UMD build only for modern browsers. { input: 'src/index.js', output: { - name: 'IndexedCache', // Name of the global object + name: 'IndexedCache', file: pkg.browser, format: 'umd', sourcemap: false @@ -23,8 +26,18 @@ export default [ }), resolve(), babel({ - exclude: 'node_modules/**', // only transpile our source code - babelHelpers: 'bundled' + exclude: 'node_modules/**', + babelHelpers: 'bundled', + presets: [ + [ + '@babel/preset-env', + { + targets: { + node: '6.5' + } + } + ] + ] }), commonjs(), production && terser({ @@ -32,6 +45,56 @@ export default [ }) ] }, + // UMD build for legacy browsers which has polyfills + es5 transpilation. + { + input: 'src/index.js', + output: { + name: 'IndexedCache', + file: pkg.browserLegacy, + format: 'umd', + sourcemap: false + }, + plugins: [ + eslint({ + throwOnError: true + }), + // Inject legacy only polyfills. + replace({ + include: './src/index.js', + preventAssignment: true, + values: { + '// INJECT_LEGACY_POLYFILL_HERE': "import './indexeddb-getall-polyfill'" + }, + delimiters: ['', ''] + }), + resolve(), + commonjs(), + babel({ + babelrc: false, + exclude: 'node_modules/**', + babelHelpers: 'bundled', + presets: [ + [ + '@babel/preset-env', + { + targets: { + browsers: '> 0.1%, not op_mini all, not dead' + }, + modules: false, + spec: true, + useBuiltIns: 'usage', + forceAllTransforms: true, + corejs: 3 + } + ] + ] + }), + production && terser({ + output: { preamble } + }) + ] + }, + // ESM build which can be used as module. { input: 'src/index.js', output: [ @@ -43,7 +106,10 @@ export default [ ], plugins: [ eslint(), - resolve() + resolve(), + production && terser({ + output: { preamble } + }) ] } ] diff --git a/src/index.js b/src/index.js index b3351b0..2af0de9 100644 --- a/src/index.js +++ b/src/index.js @@ -1,4 +1,8 @@ +// INJECT_LEGACY_POLYFILL_HERE +import { allSettled } from './polyfills' + let _icLoaded = false + export default class IndexedCache { constructor (options) { if (_icLoaded) { @@ -11,13 +15,18 @@ export default class IndexedCache { dbName: 'indexed-cache', storeName: 'objects', - // If this is enabled, all objects in the cash with keys not + // If this is enabled, all objects in the cache with keys not // found on elements on the page (data-key) will be deleted by load(). // This can be problematic in scenarios where there are multiple // pages on the same domain that have different assets, some on // certain pages and some on other. prune: false, + // Enabling this skips IndexedDB caching entirely, + // causing resources to be fetched over HTTP every time. + // Useful in dev environments. + skip: false, + // Default expiry for an object in minutes (default 3 months). // Set to null for no expiry. expiry: 131400, @@ -32,7 +41,9 @@ export default class IndexedCache { if (this.db) { return } - + if (this.opt.skip) { + return + } await this._initDB(this.opt.dbName, this.opt.storeName).then((db) => { this.db = db }).catch((e) => { @@ -41,13 +52,10 @@ export default class IndexedCache { } // Initialize the DB and then scan and setup DOM elements to cache. - async load () { + async load (elements) { // This will setup the elements on the page irrespective of whether // the DB is available or not. - let objs = [] - await this._setupElements().then((_objs) => { - objs = _objs - }) + const objs = await this._setupElements(elements) if (!this.db || objs.length === 0) { return @@ -57,12 +65,16 @@ export default class IndexedCache { // referenced on the page. if (this.opt.prune) { // Pass the list of keys found on the page. - const keys = objs.reduce((obj, v) => { obj.push(v.key); return obj }, []) + const keys = objs.map(obj => obj.key) this._prune(keys) } } deleteKey (key) { + if (!this.db) { + return + } + this._store().delete(key) } @@ -72,11 +84,15 @@ export default class IndexedCache { } clear () { + if (!this.db) { + return + } + this._store().clear() } // Initialize the indexedDB database and create the store. - async _initDB (dbName, storeName) { + _initDB (dbName, storeName) { return new Promise((resolve, reject) => { if (!window.indexedDB) { reject(new Error('indexedDB is not available')) @@ -98,102 +114,123 @@ export default class IndexedCache { req.onsuccess = () => resolve(req.result) req.onerror = (e) => reject(e.target.error) + + // Hacky fix for IndexedDB randomly locking up in Safari. + setTimeout(() => { + if (!this.db) { + reject(new Error('Opening IndexedbDB timed out')) + } + }, 200) }) } // Scan all matching elements and either: // a) if indexedDB is not available, fallback to loading the assets natively. - // b) if DB is available but the object is not cached, fetch(), cache in B, and apply the blob. + // b) if DB is available but the object is not cached, fetch(), cache in the DB, and apply the blob. // c) if DB is available and the object is cached, apply the cached blob. - - async _setupElements () { + // elements should either be null or be a NodeList. + async _setupElements (elements) { const objs = [] + // If there are no elements, scan the entire DOM for groups of each tag type. + if (elements instanceof NodeList) { + elements = Array.from(elements) + } else if (elements instanceof Node) { + elements = [elements] + } else { + const sel = this.opt.tags.map((t) => `${t}[data-src]:not([data-indexed])`).join(',') + elements = document.querySelectorAll(sel) + } + // Get all tags of a particular tag on the page that has the data-src attrib. - this.opt.tags.forEach((tag) => { - document.querySelectorAll(`${tag}[data-src]:not([data-indexed])`).forEach((el) => { - const obj = { - el: el, - key: el.dataset.key || el.dataset.src, - src: el.dataset.src, - hash: el.dataset.hash || el.dataset.src, - isAsync: el.tagName !== 'SCRIPT' || el.hasAttribute('async') || el.hasAttribute('defer'), - expiry: null - } + // document.querySelectorAll(`${tag}[data-src]:not([data-indexed])`).forEach((el) => { + Array.prototype.forEach.call(elements, (el) => { + if ('indexed' in el.dataset) { + return + } + const obj = { + el: el, + key: el.dataset.key || el.dataset.src, + src: el.dataset.src, + hash: el.dataset.hash || el.dataset.src, + isAsync: el.tagName !== 'SCRIPT' || el.hasAttribute('async') || el.hasAttribute('defer'), + expiry: null, + data: {} + } - // If there is a global expiry or an expiry on the object, compute that. - const exp = el.dataset.expiry || this.opt.expiry - if (exp) { - obj.expiry = new Date(new Date().getTime() + (parseInt(exp) * 60000)) - } + // If there is a global expiry or an expiry on the object, compute that. + const exp = el.dataset.expiry || this.opt.expiry + if (exp) { + obj.expiry = new Date(new Date().getTime() + (parseInt(exp) * 60000)) + } - // If for any reason the store is not initialized, fall back to - // the native asset loading mechanism. - if (this.db) { - objs.push(obj) - } else { - this._applyOriginal(obj) - } - }) + objs.push(obj) }) - if (objs.length === 0) { - return objs + // If there's no IndexedDB, load all scripts synchronously. + if (!this.db) { + this._applyElements(objs) + return } const promises = [] objs.forEach((obj) => { if (obj.isAsync) { // Load and apply async objects asynchronously. - this._loadObject(obj).then((result) => { - this._applyBlob(obj, result.data.blob) + this._getObject(obj).then((result) => { + this._applyElement(obj, result.data.blob) }).catch((e) => { - this._applyOriginal(obj) + this._applyElement(obj) }) } else { // Load non-async objects asynchronously (but apply synchronously). - promises.push(this._loadObject(obj)) + promises.push(this._getObject(obj)) } }) - // Once the assets have been fetched, apply them synchronously. Since - // the time take to execute a script is not guaranteed, use the onload() event - // of each element to load the next element. - await Promise.all(promises).then((results) => { - results.forEach((r, n) => { - if (n >= results.length - 1) { - return - } - - r.obj.el.onload = () => { - this._applyBlob(results[n + 1].obj, results[n + 1].data.blob) - } - }) + if (promises.length === 0) { + return objs + } - // Start the chain by loading the first element. - this._applyBlob(results[0].obj, results[0].data.blob) + // Load all elements successively. + await allSettled(promises).then((results) => { + // Promise returns [{value: { obj, data }} ...]. + // Transform to [{ ...obj, data: data} ...] + const out = results.reduce((arr, r) => { arr.push({ ...r.value.obj, data: r.value.data }); return arr }, []) + this._applyElements(out) }) return objs } - async _loadObject (obj) { + // Get the object from the DB and if that fails, fetch() it over HTTP + // This function should not reject a promise and in the case of failure, + // will return a dummy data object as if it were fetched from the DB. + _getObject (obj) { return new Promise((resolve, reject) => { // Get the stored blob. - this._getBlob(obj).then((data) => { + this._getDBblob(obj).then((data) => { resolve({ obj, data }) }).catch((e) => { // If there is no cause, the object is not cached or has expired. - if (e.toString() === '') { + if (e.toString() !== 'Error') { console.log('error getting cache blob:', e) } // Couldn't get the stored blog. Attempt to fetch() and cache. - this._fetchAsset(obj).then((data) => { + this._fetchObject(obj).then((data) => { resolve({ obj, data }) }).catch((e) => { // Everything failed. Failover to loading assets natively. - reject(new Error('error fetching asset: ' + e)) + resolve({ + obj, + data: { + key: obj.key, + hash: obj.hash, + expiry: obj.expiry, + blob: null + } + }) }) }) }) @@ -201,43 +238,49 @@ export default class IndexedCache { // Get the blob of an asset stored in the DB. If there is no entry or it has expired // (hash changed or date expired), fetch the asset over HTTP, cache it, and load it. - async _getBlob (obj) { + _getDBblob (obj) { return new Promise((resolve, reject) => { - const req = this._store().get(obj.key) - req.onsuccess = (e) => { - const data = e.target.result + try { + const req = this._store().get(obj.key) + req.onsuccess = (e) => { + const data = e.target.result + + // Reject if there is no stored data, or if the hash has changed. + if (!data || (obj.hash && (data.hash !== obj.hash))) { + reject(new Error('')) + return + } - // Reject if there is no stored data, or if the hash has changed. - if (!data || (obj.hash && (data.hash !== obj.hash))) { - reject(new Error('')) - return - } + // Reject and delete if the object has expired. + if (data.expiry && new Date() > new Date(data.expiry)) { + this.deleteKey(data.key) + reject(new Error('')) + return + } - // Reject and delete if the object has expired. - if (data.expiry && new Date() > new Date(data.expiry)) { - this.deleteKey(data.key) - reject(new Error('')) - return + resolve(data) } - resolve(data) - } - - req.onerror = (e) => { + req.onerror = (e) => { + reject(e.target.error) + } + } catch (e) { reject(e.target.error) } }) } // Fetch an asset and cache it. - async _fetchAsset (obj) { + _fetchObject (obj) { return new Promise((resolve, reject) => { fetch(obj.src).then((r) => { + // HTTP request failed. if (!r.ok) { reject(new Error(`error fetching asset: ${r.status}`)) return } + // Write the fetched blob to the DB. r.blob().then((b) => { const data = { key: obj.key, @@ -246,45 +289,61 @@ export default class IndexedCache { blob: b } - const req = this._store().put(data) - req.onsuccess = (e) => resolve(data) - req.onerror = (e) => reject(e.target.error) + // onerror() may not always trigger like in the private mode in Safari. + try { + const req = this._store().put(data) + req.onsuccess = () => resolve(data) + req.onerror = (e) => reject(e.target.error) + } catch (e) { + reject(e) + } }) - }).catch((e) => { - reject(new Error(e.toString())) - }) + }).catch((e) => reject(e)) }) } - // Fallback (because there is no DB) to loading the assets via the native mechanism. - _applyOriginal (obj) { - switch (obj.el.tagName) { - case 'SCRIPT': - case 'IMG': - obj.el.setAttribute('src', obj.src) - break - case 'LINK': - obj.el.setAttribute('href', obj.src) + // Apply the Blob (if given), or the original obj.src URL to the given element. + _applyElement (obj, blob) { + let url = obj.src + if (blob) { + url = window.URL.createObjectURL(blob) } - obj.el.dataset.indexed = true - } - // Apply the Blob() to the given element. - _applyBlob (obj, blob) { - const b = window.URL.createObjectURL(blob) switch (obj.el.tagName) { case 'SCRIPT': case 'IMG': - obj.el.src = b + obj.el.src = url break case 'LINK': - obj.el.href = b + obj.el.href = url } obj.el.dataset.indexed = true } + // Apply the Blob (if given), or the original obj.src URL to the given list of elements + // by chaining each successive element to the previous one's onload so that they load + // in order. + _applyElements (objs) { + objs.forEach((obj, n) => { + if (n >= objs.length - 1) { + return + } + + obj.el.onload = obj.el.onerror = () => { + this._applyElement(objs[n + 1], objs[n + 1].data.blob) + } + }) + + // Start the chain by loading the first element. + this._applyElement(objs[0], objs[0].data.blob) + } + // Delete all objects in cache that are not in the given list of objects. _prune (keys) { + if (!this.db) { + return + } + // Prepare a { key: true } lookup map of all keys found on the page. const keyMap = keys.reduce((obj, v) => { obj[v] = true; return obj }, {}) diff --git a/src/indexeddb-getall-polyfill.js b/src/indexeddb-getall-polyfill.js new file mode 100644 index 0000000..5fe0e21 --- /dev/null +++ b/src/indexeddb-getall-polyfill.js @@ -0,0 +1,177 @@ +/* eslint-disable */ + +// http://stackoverflow.com/a/33268326/786644 - works in browser, worker, and Node.js +var globalVar = typeof window !== 'undefined' ? window : + typeof WorkerGlobalScope !== 'undefined' ? self : + typeof global !== 'undefined' ? global : + Function('return this;')(); + +(function (window) { + "use strict"; + + var Event, IDBIndex, IDBObjectStore, IDBRequest, getAllFactory; + + IDBObjectStore = window.IDBObjectStore || window.webkitIDBObjectStore || window.mozIDBObjectStore || window.msIDBObjectStore; + IDBIndex = window.IDBIndex || window.webkitIDBIndex || window.mozIDBIndex || window.msIDBIndex; + + if (typeof IDBObjectStore === "undefined" || typeof IDBIndex === "undefined") { + return; + } + + var override = false; + + // Safari 10.1 has getAll but inside a Worker it crashes https://bugs.webkit.org/show_bug.cgi?id=172434 + if (typeof WorkerGlobalScope !== "undefined" && (navigator.userAgent.indexOf("Safari/602") >= 0 || navigator.userAgent.indexOf("Safari/603") >= 0)) { + override = true; + } + + if (!override && (IDBObjectStore.prototype.getAll !== undefined && IDBIndex.prototype.getAll !== undefined && IDBObjectStore.prototype.getAllKeys !== undefined && IDBIndex.prototype.getAllKeys !== undefined)) { + return; + } + + // IDBRequest and Event objects mostly from https://github.com/dumbmatter/fakeIndexedDB + IDBRequest = function () { + this.result = null; + this.error = null; + this.source = null; + this.transaction = null; + this.readyState = 'pending'; + this.onsuccess = null; + this.onerror = null; + + this.toString = function () { + return '[object IDBRequest]'; + }; + + this._listeners = { + success: [], + error: [], + }; + + var that = this; + this.addEventListener = function (type, listener) { + if (that._listeners[type]) { + that._listeners[type].push(listener); + } + + } + this.removeEventListener = function (type, listener) { + if (that._listeners[type]) { + that._listeners[type] = that._listeners[type] + .filter(function (listener2) { + return listener !== listener2; + }); + } + } + }; + Event = function (type) { + this.type = type; + this.target = null; + this.currentTarget = null; + + this.NONE = 0; + this.CAPTURING_PHASE = 1; + this.AT_TARGET = 2; + this.BUBBLING_PHASE = 3; + this.eventPhase = this.NONE; + + this.stopPropagation = function () { + console.log('stopPropagation not implemented in IndexedDB-getAll-shim'); + }; + this.stopImmediatePropagation = function () { + console.log('stopImmediatePropagation not implemented in IndexedDB-getAll-shim'); + }; + + this.bubbles = false; + this.cancelable = false; + this.preventDefault = function () { + console.log('preventDefault not implemented in IndexedDB-getAll-shim'); + }; + this.defaultPrevented = false; + + this.isTrusted = false; + this.timestamp = Date.now(); + }; + + // Based on spec draft https://w3c.github.io/IndexedDB/#dom-idbobjectstore-getall + getAllFactory = function (parent, type) { + return function (key, count) { + var cursorRequest, i, request, result; + + key = key !== undefined ? key : null; + + request = new IDBRequest(); + result = []; + + // this is either an IDBObjectStore or an IDBIndex, depending on the context. + cursorRequest = this.openCursor(key); + + cursorRequest.onsuccess = function (event) { + var cursor, e, i, value; + + cursor = event.target.result; + if (cursor) { + if (type === "value") { + value = cursor.value; + } else if (parent === "index") { + value = cursor.primaryKey; + } else { + value = cursor.key; + } + result.push(value); + if (count === undefined || result.length < count) { + cursor.continue(); + return; + } + } + + request.result = result; + e = new Event("success"); + e.target = { + readyState: "done", + result: result + }; + if (typeof request.onsuccess === "function") { + request.onsuccess(e); + } + if (request._listeners.success.length > 0) { + for (i = 0; i < request._listeners.success.length; i++) { + request._listeners.success[i](e); + } + } + }; + + cursorRequest.onerror = function (event) { + var i; + + console.log('IndexedDB-getAll-shim error when getting data:', event.target.error); + if (typeof request.onerror === "function") { + request.onerror(event); + } + if (request._listeners.error.length > 0) { + for (i = 0; i < request._listeners.error.length; i++) { + request._listeners.error[i](e); + } + } + }; + + return request; + }; + }; + + if (override || IDBObjectStore.prototype.getAll === undefined) { + IDBObjectStore.prototype.getAll = getAllFactory("objectStore", "value"); + } + + if (override || IDBIndex.prototype.getAll === undefined) { + IDBIndex.prototype.getAll = getAllFactory("index", "value"); + } + + if (override || IDBObjectStore.prototype.getAllKeys === undefined) { + IDBObjectStore.prototype.getAllKeys = getAllFactory("objectStore", "key"); + } + + if (override || IDBIndex.prototype.getAllKeys === undefined) { + IDBIndex.prototype.getAllKeys = getAllFactory("index", "key"); + } +}(globalVar)); diff --git a/src/polyfills.js b/src/polyfills.js new file mode 100644 index 0000000..172013e --- /dev/null +++ b/src/polyfills.js @@ -0,0 +1,7 @@ +export function allSettled (promises) { + const wrappedPromises = promises.map(p => Promise.resolve(p) + .then( + val => ({ status: 'fulfilled', value: val }), + err => ({ status: 'rejected', reason: err }))) + return Promise.all(wrappedPromises) +} diff --git a/yarn.lock b/yarn.lock index 4813ddc..43e400b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -945,6 +945,14 @@ is-module "^1.0.0" resolve "^1.19.0" +"@rollup/plugin-replace@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@rollup/plugin-replace/-/plugin-replace-3.0.0.tgz#3a4c9665d4e7a4ce2c360cf021232784892f3fac" + integrity sha512-3c7JCbMuYXM4PbPWT4+m/4Y6U60SgsnDT/cCyAyUKwFHg7pTSfsSQzIpETha3a3ig6OdOKzZz87D9ZXIK3qsDg== + dependencies: + "@rollup/pluginutils" "^3.1.0" + magic-string "^0.25.7" + "@rollup/pluginutils@^3.1.0": version "3.1.0" resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-3.1.0.tgz#706b4524ee6dc8b103b3c995533e5ad680c02b9b" @@ -1025,9 +1033,9 @@ ansi-colors@^4.1.1: integrity sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA== ansi-regex@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.0.tgz#388539f55179bf39339c81af30a654d69f87cb75" - integrity sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg== + version "5.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" + integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== ansi-styles@^3.2.1: version "3.2.1" @@ -1999,9 +2007,9 @@ minimatch@^3.0.4: brace-expansion "^1.1.7" minimist@^1.2.0, minimist@^1.2.5: - version "1.2.5" - resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" - integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== + version "1.2.6" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44" + integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q== ms@2.0.0: version "2.0.0"