UNPKG

@febkosq8/local-save

Version:

Lightweight wrapper around IndexedDB for secure and structured client-side data storage.

8 lines 15.4 kB
var m=class extends Error{constructor(e,t){super(e,t),this.name="LocalSaveEncryptionKeyError";}};var c=class extends Error{constructor(e,t){super(e,t),this.name="LocalSaveError";}};var n=class{constructor(){throw new Error("This class cannot be instantiated.")}static log(e,...t){console.log(`[LocalSave | LOG] ${e} `,...t);}static warn(e,...t){console.warn(`[LocalSave | WARN] ${e} `,...t);}static error(e,...t){console.error(`[LocalSave | ERROR] ${e} `,...t);}static info(e,...t){console.info(`[LocalSave | INFO] ${e} `,...t);}static debug(e,...t){console.debug(`[LocalSave | DEBUG] ${e} `,...t);}};function v(u){return typeof u=="string"&&u.length>0}function L(u){return u.length>0&&!/\s/.test(u)&&[16,24,32].includes(u.length)}function C(u){let e="",t=new Uint8Array(u),r=t.byteLength;for(let o=0;o<r;o++)e+=String.fromCharCode(t[o]);return window.btoa(e)}function $(u){let e=window.atob(u),t=e.length,r=new Uint8Array(t);for(let o=0;o<t;o++)r[o]=e.charCodeAt(o);return r.buffer}var D=class{textEncoder;textDecoder;cachedCryptoKeyPromise;cachedCryptoKeySource;getEncryptionKey;isLoggingEnabled;constructor(e){this.getEncryptionKey=e.getEncryptionKey,this.isLoggingEnabled=e.isLoggingEnabled;}getTextEncoder(){return this.textEncoder||(this.textEncoder=new TextEncoder),this.textEncoder}getTextDecoder(){return this.textDecoder||(this.textDecoder=new TextDecoder),this.textDecoder}async getEncryptKey(){let e=this.getEncryptionKey();if(!v(e))throw new m("Encryption key is not configured");if(!L(e))throw new m("Encryption key should not contain spaces and should be of length 16, 24, or 32 characters");if(this.cachedCryptoKeyPromise&&this.cachedCryptoKeySource===e)return this.cachedCryptoKeyPromise;let t=this.getTextEncoder().encode(e),r=crypto.subtle.importKey("raw",t,{name:"AES-GCM"},false,["encrypt","decrypt"]).then(o=>(this.isLoggingEnabled()&&n.debug("Encryption key retrieved successfully",{keyLength:e.length,keyBytesLength:t.length}),o)).catch(o=>{throw this.cachedCryptoKeyPromise===r&&(this.cachedCryptoKeyPromise=void 0,this.cachedCryptoKeySource=void 0),o});return this.cachedCryptoKeySource=e,this.cachedCryptoKeyPromise=r,r}async encryptData(e){try{if(!v(this.getEncryptionKey()))throw new m("Encryption key is not configured");let t=window.crypto.getRandomValues(new Uint8Array(12)),r=await this.getEncryptKey(),o=this.getTextEncoder().encode(JSON.stringify(e)),s=await window.crypto.subtle.encrypt({name:"AES-GCM",iv:t},r,o),i=new Uint8Array(t),l=new Uint8Array(s),d=new Uint8Array(i.byteLength+l.byteLength);d.set(i,0),d.set(l,i.byteLength);let a=C(d.buffer);return this.isLoggingEnabled()&&n.debug("Data encrypted successfully",{base64DataLength:a.length}),a}catch(t){throw this.isLoggingEnabled()&&n.error("Data encryption failed",t),t}}async decryptData(e){try{if(!v(this.getEncryptionKey()))throw new m("Encryption key is not configured");let t=$(e),r=new Uint8Array(t,0,12),o=await this.getEncryptKey(),s=new Uint8Array(t,12),i=await window.crypto.subtle.decrypt({name:"AES-GCM",iv:r},o,s),l=JSON.parse(this.getTextDecoder().decode(i));return this.isLoggingEnabled()&&n.debug("Data decrypted successfully",{timestamp:l.timestamp}),l}catch(t){throw this.isLoggingEnabled()&&n.error("Data decryption failed",t),new c("Data decryption failed",{cause:t instanceof Error?t:void 0})}}};function T({request:u,transaction:e,onRequestError:t,onTransactionError:r,onTransactionAbort:o,onTransactionComplete:s}){return new Promise((i,l)=>{let d=false,a,g=()=>{d||(d=true,i(true));},p=h=>{d||(d=true,l(h));};u.addEventListener("error",()=>{a=t(u.error);},{once:true}),e.addEventListener("complete",()=>{if(!d){if(a){p(a);return}s?.(),g();}},{once:true}),e.addEventListener("error",()=>{d||p(a??r(e.error));},{once:true}),e.addEventListener("abort",()=>{d||p(a??o(e.error));},{once:true});})}var E=class extends Error{constructor(e,t){super(e,t),this.name="LocalSaveConfigError";}};var S=class{dbName="LocalSave";encryptionKey;crypto;categories=["userData"];expiryThreshold=720*60*60*1e3;blockedTimeoutThreshold=10*1e3;clearOnDecryptError=true;printLogs=false;constructor(e){if(this.dbName=e?.dbName??this.dbName,this.encryptionKey=e?.encryptionKey,this.categories=e?.categories??this.categories,this.clearOnDecryptError=e?.clearOnDecryptError??this.clearOnDecryptError,this.expiryThreshold=e?.expiryThreshold??this.expiryThreshold,this.blockedTimeoutThreshold=e?.blockedTimeoutThreshold??this.blockedTimeoutThreshold,this.printLogs=e?.printLogs??this.printLogs,this.crypto=new D({getEncryptionKey:()=>this.encryptionKey,isLoggingEnabled:()=>this.printLogs}),e?.encryptionKey!==void 0&&!L(e.encryptionKey))throw new E("Encryption key should not contain spaces and should be of length 16, 24, or 32 characters");if(typeof this.expiryThreshold!="number"||!Number.isFinite(this.expiryThreshold)||this.expiryThreshold<=0)throw new E("expiryThreshold should be a positive number");if(typeof this.blockedTimeoutThreshold!="number"||!Number.isFinite(this.blockedTimeoutThreshold)||this.blockedTimeoutThreshold<=0)throw new E("blockedTimeoutThreshold should be a positive number")}openDB(e){return new Promise((t,r)=>{let o=indexedDB.open(this.dbName,e),s=false,i,l=a=>{if(s){a.close();return}s=true,i&&clearTimeout(i),t(a);},d=a=>{s||(s=true,i&&clearTimeout(i),r(a));};o.onupgradeneeded=()=>{let a=o.result;this.printLogs&&n.debug("Database upgrade triggered",{dbName:this.dbName,version:a.version});for(let g of this.categories)a.objectStoreNames.contains(g)||(this.printLogs&&n.debug("Creating object store",{category:g}),a.createObjectStore(g));},o.onsuccess=()=>{let a=o.result;a.onversionchange=()=>{this.printLogs&&n.warn(`Closing stale database connection on version change [dbName:${this.dbName}]`),a.close();},this.printLogs&&n.debug("Database opened successfully",{dbName:this.dbName,version:a.version}),l(a);},o.onerror=()=>{this.printLogs&&n.error(`LocalSaveError opening database [dbName:${this.dbName}]`,o.error),d(new c(o.error?.message??"Error opening database"));},o.onblocked=()=>{this.printLogs&&n.warn(`Opening database is currently blocked by an existing open connection. Waiting for ${this.blockedTimeoutThreshold} ms before timing out [dbName:${this.dbName}]`),!(i||s)&&(i=setTimeout(()=>{d(new c(`Opening database timed out after ${this.blockedTimeoutThreshold} ms because it is blocked by open connections`));},this.blockedTimeoutThreshold));};})}async listStores(){let e=await this.openDB();try{let t=Array.from(e.objectStoreNames);return this.printLogs&&n.debug("Object stores listed successfully",{stores:t}),t}finally{e.close();}}async getStore(e,t="readonly"){let r=await this.openDB();if(!r.objectStoreNames.contains(e)&&this.categories.includes(e)){this.printLogs&&n.debug(`Requested object store not found in current database version. Triggering database upgrade to create object store.`,{category:e,dbName:this.dbName,version:r.version});let i=r.version;r.close(),r=await this.openDB(i+1);}else if(!r.objectStoreNames.contains(e)){let i=r.version;throw r.close(),new c(`Requested object store not found in current database version [category:${e} / dbName:${this.dbName} / version:${i}].`)}let o;try{o=r.transaction(e,t);}catch(i){throw r.close(),new c(i instanceof Error?i.message:"Error creating transaction")}o.oncomplete=()=>{r.close();},o.onerror=()=>{r.close();},o.onabort=()=>{r.close();};let s=o.objectStore(e);return this.printLogs&&n.debug("Object store retrieved from database",{category:e,mode:t,dbName:this.dbName,version:r.version}),s}async encryptData(e){return this.crypto.encryptData(e)}async decryptData(e){return this.crypto.decryptData(e)}async set(e,t,r){this.printLogs&&n.debug("set() called to store data with following props",{category:e,itemKey:t});let o={timestamp:Date.now(),data:r};try{this.encryptionKey&&(o=await this.encryptData(o));let s=await this.getStore(e,"readwrite");return T({request:s.put(o,t),transaction:s.transaction,onRequestError:i=>(this.printLogs&&n.error(`LocalSaveError storing data [category:${e} / key:${t}]`,i),new c(i?.message??"Error storing data",{cause:i??void 0})),onTransactionError:i=>(this.printLogs&&n.error(`LocalSaveError during transaction commit while storing data [category:${e} / key:${t}]`,i),new c(i?.message??"Error committing storage transaction",{cause:i??void 0})),onTransactionAbort:i=>(this.printLogs&&n.warn(`Transaction aborted while storing data [category:${e} / key:${t}]`,i),new c(i?.message??"Transaction aborted while storing data",{cause:i??void 0})),onTransactionComplete:()=>{this.printLogs&&n.debug("Data stored successfully",{category:e,itemKey:t});}})}catch(s){throw this.printLogs&&n.error("Data storing failed",s),s}}async get(e,t){this.printLogs&&n.debug("get() called to retrieve data with following props",{category:e,itemKey:t});let r=await this.getStore(e);return new Promise((o,s)=>{let i=r.get(t);i.onsuccess=async()=>{let l=i.result;if(!l)return this.printLogs&&n.debug("No data was found",{category:e,itemKey:t}),o(null);if(typeof l=="string")try{l=await this.decryptData(l);}catch(d){if(this.printLogs&&n.error("Failed to get data",d),this.clearOnDecryptError){this.printLogs&&n.error("Triggering clear for all data for category since decryption failed");try{await this.clear(e);}catch(a){return this.printLogs&&n.error("Failed to clear data after decryption failure",a),s(new c("Data decryption failed and category clearing failed",{cause:a instanceof Error?a:void 0}))}}return s(new c(d instanceof Error?d.message:"Failed to decrypt data"))}return this.printLogs&&n.debug("Data retrieved successfully",{category:e,itemKey:t,timestamp:l.timestamp}),o(l)},i.onerror=()=>s(new c(i.error?.message??"Error getting data"));})}async listCategories(){return this.printLogs&&n.debug("listCategories() called to list all categories"),this.listStores()}async listKeys(e){this.printLogs&&n.debug("listKeys() called to list all keys for category",{category:e});let t=await this.getStore(e);return new Promise((r,o)=>{let s=t.getAllKeys();s.onsuccess=()=>{let i=s.result;this.printLogs&&n.debug("Keys listed successfully for category",{category:e,keys:i}),r(i);},s.onerror=()=>{this.printLogs&&n.error(`Error listing keys for category [category:${e}]`,s.error),o(new c(s.error?.message??"Error listing keys"));};})}async remove(e,t){this.printLogs&&n.debug("remove() called to remove data with following props",{category:e,itemKey:t});let r=await this.getStore(e,"readwrite");return T({request:r.delete(t),transaction:r.transaction,onRequestError:o=>(this.printLogs&&n.error(`Failed to remove data from [category:${e} / key:${t}]`,o),new c(o?.message??"Error removing data",{cause:o??void 0})),onTransactionError:o=>(this.printLogs&&n.error(`LocalSaveError during transaction commit while removing data [category:${e} / key:${t}]`,o),new c(o?.message??"Error committing removal transaction",{cause:o??void 0})),onTransactionAbort:o=>(this.printLogs&&n.warn(`Transaction aborted while removing data [category:${e} / key:${t}]`,o),new c(o?.message??"Transaction aborted while removing data",{cause:o??void 0})),onTransactionComplete:()=>{this.printLogs&&n.debug("Data removed successfully",{category:e,itemKey:t});}})}async clear(e){this.printLogs&&n.debug(`clear() called to store all data under '${e}' category`);let t=await this.getStore(e,"readwrite");return T({request:t.clear(),transaction:t.transaction,onRequestError:r=>(this.printLogs&&n.error(`LocalSaveError clearing data [category:${e} / dbName:${this.dbName} / version:${t.transaction.db.version}]`,r),new c(r?.message??"Error clearing data",{cause:r??void 0})),onTransactionError:r=>(this.printLogs&&n.error(`LocalSaveError during transaction commit while clearing data [category:${e} / dbName:${this.dbName} / version:${t.transaction.db.version}]`,r),new c(r?.message??"Error committing clear transaction",{cause:r??void 0})),onTransactionAbort:r=>(this.printLogs&&n.warn(`Transaction aborted while clearing data [category:${e}]`,r),new c(r?.message??"Transaction aborted while clearing data",{cause:r??void 0})),onTransactionComplete:()=>{this.printLogs&&n.debug("Data cleared successfully",{category:e,dbName:this.dbName,version:t.transaction.db.version});}})}async expire(e=this.expiryThreshold){if(this.printLogs&&n.debug(`expire() called to expire data older than ${e} ms`),typeof e!="number"||!Number.isFinite(e)||e<=0)throw new c("thresholdMs should be a positive number");let t=Date.now()-e;for(let r of this.categories){let o=await this.getStore(r);try{let s=[],i=0;if(await new Promise((d,a)=>{let g=o.openCursor(),p=!1,h=!1,f=0,y,B=b=>{p||(p=!0,a(b));},k=()=>{if(!p){if(y){B(y);return}h&&f===0&&(p=!0,d());}};g.onsuccess=()=>{if(p||y)return;let b=g.result;if(!b){h=!0,this.printLogs&&n.debug("Entries scanned successfully for expiring data",{category:r,entryCount:i}),k();return}if(i+=1,typeof b.key=="string"){let N=b.key,x=b.value;typeof x=="string"?(f+=1,this.decryptData(x).then(w=>{w.timestamp<t&&s.push(N);}).catch(w=>{if(this.printLogs&&n.error("Failed to decrypt data while expiring",w),this.clearOnDecryptError&&(this.printLogs&&n.error("Triggering clear for all data for category since decryption failed during expire"),this.clear(r)),!y){y=new c(w instanceof Error?w.message:"Failed to decrypt data");try{o.transaction.abort();}catch{}}}).finally(()=>{f-=1,k();})):x.timestamp<t&&s.push(N);}y||b.continue();},g.onerror=()=>{this.printLogs&&n.error(`LocalSaveError scanning entries for expiring data [category:${r}]`,g.error),B(new c(g.error?.message??"Error scanning entries"));};}),s.length===0)continue;let l=await this.getStore(r,"readwrite");await new Promise((d,a)=>{let g=!1,p=f=>{g||(g=!0,a(f));},h=l.transaction;h.addEventListener("complete",()=>{g||(g=!0,this.printLogs&&n.debug("Expired data removed successfully",{category:r,removedCount:s.length}),d());}),h.addEventListener("error",()=>{this.printLogs&&n.error(`LocalSaveError during transaction commit while removing expired data [category:${r}]`,h.error),p(new c(h.error?.message??"Error committing removal transaction"));}),h.addEventListener("abort",()=>{this.printLogs&&n.warn(`Transaction aborted while removing expired data [category:${r}]`),p(new c("Transaction aborted while removing expired data"));});for(let f of s){let y=l.delete(f);y.onerror=()=>{this.printLogs&&n.error(`LocalSaveError removing expired data [category:${r} / key:${f}]`,y.error);};}});}catch(s){throw this.printLogs&&n.error(`Expiring data older than '${e}' ms failed`,s),s}}return true}async destroy(){return this.printLogs&&n.debug("destroy() called to wipe all data under all categories"),new Promise((e,t)=>{let r=indexedDB.deleteDatabase(this.dbName),o=false,s,i=()=>{o||(o=true,s&&clearTimeout(s),e(true));},l=d=>{o||(o=true,s&&clearTimeout(s),t(d));};r.onsuccess=()=>{this.printLogs&&n.debug("Database deleted successfully",{dbName:this.dbName}),i();},r.onerror=()=>{this.printLogs&&n.error(`Error deleting database [dbName:${this.dbName}]`),l(new c(r.error?.message??"Error deleting database"));},r.onblocked=()=>{this.printLogs&&n.warn(`Deleting database is currently blocked by an open connection. Waiting for ${this.blockedTimeoutThreshold} ms before timing out [dbName:${this.dbName}]`),!(s||o)&&(s=setTimeout(()=>{l(new c(`Deleting database timed out after ${this.blockedTimeoutThreshold} ms because it is blocked by open connections`));},this.blockedTimeoutThreshold));};})}},H=S;export{H as default};//# sourceMappingURL=index.js.map //# sourceMappingURL=index.js.map