diff --git a/changelog.txt b/changelog.txt index 084ad4d..c93e8c3 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,3 +1,11 @@ + +================================================================================ + v1.1.1 +================================================================================ +Added: Expiration option, expires works with minutes, + or you can send a datetime and set the option + `isExpiresDate` to true. + ================================================================================ v1.1.0 ================================================================================ diff --git a/package-lock.json b/package-lock.json index 047a49f..c935ee3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "immortal-db", - "version": "1.1.0", + "version": "1.1.1", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index f663056..321d293 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "immortal-db", "private": false, - "version": "1.1.0", + "version": "1.1.1", "main": "dist/immortal-db.js", "module": "src/index.js", "types": "immortal-db.d.ts", diff --git a/src/cookie-store.js b/src/cookie-store.js index 4792cbe..f03b436 100644 --- a/src/cookie-store.js +++ b/src/cookie-store.js @@ -17,8 +17,8 @@ const DEFAULT_COOKIE_TTL = 365 // Days. // https://tools.ietf.org/html/draft-west-cookie-incrementalism-00 for // details on SameSite and cross-origin behavior. const CROSS_ORIGIN_IFRAME = amIInsideACrossOriginIframe() -const DEFAULT_SECURE = (CROSS_ORIGIN_IFRAME ? true : false) -const DEFAULT_SAMESITE = (CROSS_ORIGIN_IFRAME ? 'None' : 'Lax') +const DEFAULT_SECURE = !!CROSS_ORIGIN_IFRAME +const DEFAULT_SAMESITE = CROSS_ORIGIN_IFRAME ? 'None' : 'Lax' function amIInsideACrossOriginIframe () { try { @@ -28,7 +28,7 @@ function amIInsideACrossOriginIframe () { // If inside a cross-origin iframe, raises: Uncaught // DOMException: Blocked a frame with origin "..." from // accessing a cross-origin frame. - return !Boolean(window.top.location.href) + return !window.top.location.href } catch (err) { return true } @@ -36,9 +36,10 @@ function amIInsideACrossOriginIframe () { class CookieStore { constructor ({ - ttl = DEFAULT_COOKIE_TTL, - secure = DEFAULT_SECURE, - sameSite = DEFAULT_SAMESITE} = {}) { + ttl = DEFAULT_COOKIE_TTL, + secure = DEFAULT_SECURE, + sameSite = DEFAULT_SAMESITE, + } = {}) { this.ttl = ttl this.secure = secure this.sameSite = sameSite @@ -48,23 +49,45 @@ class CookieStore { async get (key) { const value = Cookies.get(key) + console.log(Cookies.expires) return typeof value === 'string' ? value : undefined } - async set (key, value) { - Cookies.set(key, value, this._constructCookieParams()) + async set (key, value, options = { expires: 0, isExpiresDate: false }) { + let opts = {} + if (options && options.expires) { + opts.expires = options.isExpiresDate + ? new Date(options.expires) + : new Date(new Date().getTime() + options.expires * 60 * 1000) + } + Cookies.set(key, value, this._constructCookieParams(opts)) } async remove (key) { Cookies.remove(key, this._constructCookieParams()) } - _constructCookieParams () { - return { - expires: this.ttl, + _constructCookieParams ( + options = { + expires: this.tll, + secure: this.secure, + sameSite: this.sameSite, + }, + ) { + const opts = { + expires: this.tll, secure: this.secure, sameSite: this.sameSite, } + + const keys = Object.keys(opts) + for (let i = 0; i < keys.length; i++) { + if (options[keys[i]]) { + opts[keys[i]] = options[keys[i]] + } + } + + return opts } } diff --git a/src/index.js b/src/index.js index e1a2faf..9efecd3 100644 --- a/src/index.js +++ b/src/index.js @@ -14,7 +14,7 @@ import { LocalStorageStore, SessionStorageStore } from './web-storage' const cl = console.log const DEFAULT_KEY_PREFIX = '_immortal|' -const WINDOW_IS_DEFINED = (typeof window !== 'undefined') +const WINDOW_IS_DEFINED = typeof window !== 'undefined' // Stores must implement asynchronous constructor, get(), set(), and // remove() methods. @@ -92,21 +92,25 @@ class ImmortalStorage { // classes (whose definitions are synchronous) must be accepted in // addition to instantiated store objects. this.onReady = (async () => { - this.stores = (await Promise.all( - stores.map(async StoreClassOrInstance => { - if (typeof StoreClassOrInstance === 'object') { // Store instance. - return StoreClassOrInstance - } else { // Store class. - try { - return await new StoreClassOrInstance() // Instantiate instance. - } catch (err) { - // TODO(grun): Log (where?) that the constructor Promise - // failed. - return null + this.stores = ( + await Promise.all( + stores.map(async StoreClassOrInstance => { + if (typeof StoreClassOrInstance === 'object') { + // Store instance. + return StoreClassOrInstance + } else { + // Store class. + try { + return await new StoreClassOrInstance() // Instantiate instance. + } catch (err) { + // TODO(grun): Log (where?) that the constructor Promise + // failed. + return null + } } - } - }), - )).filter(Boolean) + }), + ) + ).filter(Boolean) })() } @@ -125,6 +129,28 @@ class ImmortalStorage { }), ) + const expiresValues = await Promise.all( + this.stores.map(async store => { + try { + return await store.getExpires(prefixedKey) + } catch (err) {} + }), + ) + + const countedExpires = Array.from(countUniques(expiresValues).entries()) + countedExpires.sort((a, b) => a[1] <= b[1]) + let expires + const [firstVal, firstCo] = arrayGet(countedExpires, 0, [undefined, 0]) + const [secondVal, secondCo] = arrayGet(countedExpires, 1, [undefined, 0]) + if ( + firstCo > secondCo || + (firstCo === secondCo && firstVal !== undefined) + ) { + expires = firstVal + } else { + expires = secondVal + } + const counted = Array.from(countUniques(values).entries()) counted.sort((a, b) => a[1] <= b[1]) @@ -141,7 +167,7 @@ class ImmortalStorage { } if (value !== undefined) { - await this.set(key, value) + await this.set(key, value, { expires, isExpiresDate: true }) return value } else { await this.remove(key) @@ -149,7 +175,7 @@ class ImmortalStorage { } } - async set (key, value) { + async set (key, value, options = { expires: 0 }) { await this.onReady key = `${DEFAULT_KEY_PREFIX}${key}` @@ -157,7 +183,7 @@ class ImmortalStorage { await Promise.all( this.stores.map(async store => { try { - await store.set(key, value) + await store.set(key, value, options) } catch (err) { cl(err) } diff --git a/src/indexed-db.js b/src/indexed-db.js index 3e709cc..6841973 100644 --- a/src/indexed-db.js +++ b/src/indexed-db.js @@ -17,10 +17,16 @@ import { const DEFAULT_DATABASE_NAME = 'ImmortalDB' const DEFAULT_STORE_NAME = 'key-value-pairs' +const DEFAULT_EXPIRES_DB_NAME = 'ImmortalDBExp' class IndexedDbStore { - constructor (dbName = DEFAULT_DATABASE_NAME, storeName = DEFAULT_STORE_NAME) { + constructor ( + dbName = DEFAULT_DATABASE_NAME, + storeName = DEFAULT_STORE_NAME, + expiresDBName = DEFAULT_EXPIRES_DB_NAME, + ) { this.store = new Store(dbName, storeName) + this.expiresStore = new Store(expiresDBName, storeName) return (async () => { // Safari throws a SecurityError if IndexedDB.open() is called in a @@ -34,6 +40,7 @@ class IndexedDbStore { // Safari. Push the fix(es) upstream. try { await this.store._dbp + await this.expiresStore._dbp } catch (err) { if (err.name === 'SecurityError') { return null // Failed to open an IndexedDB database. @@ -47,17 +54,49 @@ class IndexedDbStore { } async get (key) { + const val = await this.getExpires(key) + if (val && val <= new Date().getTime()) { + await this.remove(key) + } + const value = await idbGet(key, this.store) return typeof value === 'string' ? value : undefined } - async set (key, value) { + async set (key, value, options = { expires: 0, isExpiresDate: false }) { await idbSet(key, value, this.store) + + if (options && options.expires) { + // If expire exists, update or add it. + await idbSet( + key, + options.isExpiresDate + ? options.expires.toString() + : new Date(new Date().getTime() + options.expires * 60 * 1000) + .getTime() + .toString(), + this.expiresStore, + ) + } else { + // If it doesn't exist, remove any existing expiration + await idbRemove(key, this.expiresStore) + } + } + + async getExpires (key) { + const value = await idbGet(key, this.expiresStore) + return typeof value === 'string' ? +value : 0 } async remove (key) { + await idbRemove(key, this.expiresStore) await idbRemove(key, this.store) } } -export { IndexedDbStore, DEFAULT_DATABASE_NAME, DEFAULT_STORE_NAME } +export { + IndexedDbStore, + DEFAULT_DATABASE_NAME, + DEFAULT_STORE_NAME, + DEFAULT_EXPIRES_DB_NAME, +} diff --git a/src/web-storage.js b/src/web-storage.js index 59270f9..09e7a47 100644 --- a/src/web-storage.js +++ b/src/web-storage.js @@ -8,24 +8,53 @@ // License: MIT // +const EXPIRES_PREFIX = 'exp_' + class StorageApiWrapper { - constructor (store) { + constructor (store, expiresPrefix = EXPIRES_PREFIX) { this.store = store + this.expiresPrefix = expiresPrefix return (async () => this)() } async get (key) { + const val = await this.getExpires(key) + if (val && val <= new Date().getTime()) { + await this.remove(key) + } + const value = this.store.getItem(key) return typeof value === 'string' ? value : undefined } - async set (key, value) { + async set (key, value, options = { expires: 0, isExpiresDate: false }) { this.store.setItem(key, value) + + if (options && options.expires) { + // If expire exists, update or add it. + this.store.setItem( + this.expiresPrefix + key, + options.isExpiresDate + ? options.expires.toString() + : new Date(new Date().getTime() + options.expires * 60 * 1000) + .getTime() + .toString(), + ) + } else { + // If it doesn't exist, remove any existing expiration + this.store.removeItem(this.expiresPrefix + key) + } + } + + async getExpires (key) { + const value = await this.store.getItem(this.expiresPrefix + key) + return typeof value === 'string' ? +value : 0 } async remove (key) { this.store.removeItem(key) + this.store.removeItem(this.expiresPrefix + key) } } diff --git a/testing/test.html b/testing/test.html index e9f9b86..bc51a75 100644 --- a/testing/test.html +++ b/testing/test.html @@ -38,7 +38,10 @@ Key

- Value + Value
+
+ Expires
+
diff --git a/testing/test.js b/testing/test.js index 51e4feb..c1e7a9c 100644 --- a/testing/test.js +++ b/testing/test.js @@ -12,6 +12,8 @@ const cl = console.log const CookieStore = ImmortalDB.CookieStore const ImmortalStorage = ImmortalDB.ImmortalStorage const idb = new idbKeyval.Store('ImmortalDB', 'key-value-pairs') +const idbExpires = new idbKeyval.Store('ImmortalDB', 'key-value-expires') + const POLL_TIMEOUT = 300 // Milliseconds. const PREFIX = ImmortalDB.DEFAULT_KEY_PREFIX @@ -40,11 +42,15 @@ function $ele (id) { const $key = $ele('key') const $value = $ele('value') + const $expires = $ele('expires') $ele('get').addEventListener( 'click', async () => $value.value = await db.get($key.value), false) $ele('set').addEventListener( - 'click', async () => await db.set($key.value, $value.value), false) + 'click', async () => { + const expires = +$expires.value + await db.set($key.value, $value.value, { expires }) + }, false) $ele('remove').addEventListener('click', async () => { await db.remove($key.value) $value.value = ''