/* * Copyright 2017-2017 Amazon.com, Inc. or its affiliates. All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance with * the License. A copy of the License is located at * * http://aws.amazon.com/apache2.0/ * * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR * CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions * and limitations under the License. */ import { defaultConfig, getCurrTime } from './Utils'; import { StorageCache } from './StorageCache'; import { ICache, CacheConfig, CacheItem, CacheItemOptions } from './types'; import { ConsoleLogger as Logger } from '@aws-amplify/core'; const logger = new Logger('Cache'); /** * Customized storage based on the SessionStorage or LocalStorage with LRU implemented */ export class BrowserStorageCacheClass extends StorageCache implements ICache { /** * initialize the cache * @param config - the configuration of the cache */ constructor(config?: CacheConfig) { const cacheConfig = config ? Object.assign({}, defaultConfig, config) : defaultConfig; super(cacheConfig); this.config.storage = cacheConfig.storage; this.getItem = this.getItem.bind(this); this.setItem = this.setItem.bind(this); this.removeItem = this.removeItem.bind(this); } /** * decrease current size of the cache * * @private * @param amount - the amount of the cache size which needs to be decreased */ private _decreaseCurSizeInBytes(amount: number): void { const curSize: number = this.getCacheCurSize(); this.config.storage.setItem( this.cacheCurSizeKey, (curSize - amount).toString() ); } /** * increase current size of the cache * * @private * @param amount - the amount of the cache szie which need to be increased */ private _increaseCurSizeInBytes(amount: number): void { const curSize: number = this.getCacheCurSize(); this.config.storage.setItem( this.cacheCurSizeKey, (curSize + amount).toString() ); } /** * update the visited time if item has been visited * * @private * @param item - the item which need to be refreshed * @param prefixedKey - the key of the item * * @return the refreshed item */ private _refreshItem(item: CacheItem, prefixedKey: string): CacheItem { item.visitedTime = getCurrTime(); this.config.storage.setItem(prefixedKey, JSON.stringify(item)); return item; } /** * check wether item is expired * * @private * @param key - the key of the item * * @return true if the item is expired. */ private _isExpired(key: string): boolean { const text: string | null = this.config.storage.getItem(key); const item: CacheItem = JSON.parse(text); if (getCurrTime() >= item.expires) { return true; } return false; } /** * delete item from cache * * @private * @param prefixedKey - the key of the item * @param size - optional, the byte size of the item */ private _removeItem(prefixedKey: string, size?: number): void { const itemSize: number = size ? size : JSON.parse(this.config.storage.getItem(prefixedKey)).byteSize; this._decreaseCurSizeInBytes(itemSize); // remove the cache item this.config.storage.removeItem(prefixedKey); } /** * put item into cache * * @private * @param prefixedKey - the key of the item * @param itemData - the value of the item * @param itemSizeInBytes - the byte size of the item */ private _setItem(prefixedKey: string, item: CacheItem): void { // update the cache size this._increaseCurSizeInBytes(item.byteSize); try { this.config.storage.setItem(prefixedKey, JSON.stringify(item)); } catch (setItemErr) { // if failed, we need to rollback the cache size this._decreaseCurSizeInBytes(item.byteSize); logger.error(`Failed to set item ${setItemErr}`); } } /** * total space needed when poping out items * * @private * @param itemSize * * @return total space needed */ private _sizeToPop(itemSize: number): number { const spaceItemNeed = this.getCacheCurSize() + itemSize - this.config.capacityInBytes; const cacheThresholdSpace = (1 - this.config.warningThreshold) * this.config.capacityInBytes; return spaceItemNeed > cacheThresholdSpace ? spaceItemNeed : cacheThresholdSpace; } /** * see whether cache is full * * @private * @param itemSize * * @return true if cache is full */ private _isCacheFull(itemSize: number): boolean { return itemSize + this.getCacheCurSize() > this.config.capacityInBytes; } /** * scan the storage and find out all the keys owned by this cache * also clean the expired keys while scanning * * @private * * @return array of keys */ private _findValidKeys(): string[] { const keys: string[] = []; const keyInCache: string[] = []; // get all keys in Storage for (let i = 0; i < this.config.storage.length; i += 1) { keyInCache.push(this.config.storage.key(i)); } // find those items which belong to our cache and also clean those expired items for (let i = 0; i < keyInCache.length; i += 1) { const key: string = keyInCache[i]; if ( key.indexOf(this.config.keyPrefix) === 0 && key !== this.cacheCurSizeKey ) { if (this._isExpired(key)) { this._removeItem(key); } else { keys.push(key); } } } return keys; } /** * get all the items we have, sort them by their priority, * if priority is same, sort them by their last visited time * pop out items from the low priority (5 is the lowest) * * @private * @param keys - all the keys in this cache * @param sizeToPop - the total size of the items which needed to be poped out */ private _popOutItems(keys: string[], sizeToPop: number): void { const items: CacheItem[] = []; let remainedSize: number = sizeToPop; // get the items from Storage for (let i = 0; i < keys.length; i += 1) { const val: string | null = this.config.storage.getItem(keys[i]); if (val != null) { const item: CacheItem = JSON.parse(val); items.push(item); } } // first compare priority // then compare visited time items.sort((a, b) => { if (a.priority > b.priority) { return -1; } else if (a.priority < b.priority) { return 1; } else { if (a.visitedTime < b.visitedTime) { return -1; } else return 1; } }); for (let i = 0; i < items.length; i += 1) { // pop out items until we have enough room for new item this._removeItem(items[i].key, items[i].byteSize); remainedSize -= items[i].byteSize; if (remainedSize <= 0) { return; } } } /** * Set item into cache. You can put number, string, boolean or object. * The cache will first check whether has the same key. * If it has, it will delete the old item and then put the new item in * The cache will pop out items if it is full * You can specify the cache item options. The cache will abort and output a warning: * If the key is invalid * If the size of the item exceeds itemMaxSize. * If the value is undefined * If incorrect cache item configuration * If error happened with browser storage * * @param key - the key of the item * @param value - the value of the item * @param {Object} [options] - optional, the specified meta-data */ public setItem( key: string, value: object | number | string | boolean, options?: CacheItemOptions ): void { logger.log( `Set item: key is ${key}, value is ${value} with options: ${options}` ); const prefixedKey: string = this.config.keyPrefix + key; // invalid keys if ( prefixedKey === this.config.keyPrefix || prefixedKey === this.cacheCurSizeKey ) { logger.warn(`Invalid key: should not be empty or 'CurSize'`); return; } if (typeof value === 'undefined') { logger.warn(`The value of item should not be undefined!`); return; } const cacheItemOptions: CacheItemOptions = { priority: options && options.priority !== undefined ? options.priority : this.config.defaultPriority, expires: options && options.expires !== undefined ? options.expires : this.config.defaultTTL + getCurrTime(), }; if (cacheItemOptions.priority < 1 || cacheItemOptions.priority > 5) { logger.warn( `Invalid parameter: priority due to out or range. It should be within 1 and 5.` ); return; } const item: CacheItem = this.fillCacheItem( prefixedKey, value, cacheItemOptions ); // check wether this item is too big; if (item.byteSize > this.config.itemMaxSize) { logger.warn( `Item with key: ${key} you are trying to put into is too big!` ); return; } try { // first look into the storage, if it exists, delete it. const val: string | null = this.config.storage.getItem(prefixedKey); if (val) { this._removeItem(prefixedKey, JSON.parse(val).byteSize); } // check whether the cache is full if (this._isCacheFull(item.byteSize)) { const validKeys: string[] = this._findValidKeys(); // check again and then pop out items if (this._isCacheFull(item.byteSize)) { const sizeToPop: number = this._sizeToPop(item.byteSize); this._popOutItems(validKeys, sizeToPop); } } // put item in the cache // may failed due to storage full this._setItem(prefixedKey, item); } catch (e) { logger.warn(`setItem failed! ${e}`); } } /** * Get item from cache. It will return null if item doesn’t exist or it has been expired. * If you specified callback function in the options, * then the function will be executed if no such item in the cache * and finally put the return value into cache. * Please make sure the callback function will return the value you want to put into the cache. * The cache will abort output a warning: * If the key is invalid * If error happened with browser storage * * @param key - the key of the item * @param {Object} [options] - the options of callback function * * @return - return the value of the item */ public getItem(key: string, options?: CacheItemOptions): any { logger.log(`Get item: key is ${key} with options ${options}`); let ret: string | null = null; const prefixedKey: string = this.config.keyPrefix + key; if ( prefixedKey === this.config.keyPrefix || prefixedKey === this.cacheCurSizeKey ) { logger.warn(`Invalid key: should not be empty or 'CurSize'`); return null; } try { ret = this.config.storage.getItem(prefixedKey); if (ret != null) { if (this._isExpired(prefixedKey)) { // if expired, remove that item and return null this._removeItem(prefixedKey, JSON.parse(ret).byteSize); ret = null; } else { // if not expired, great, return the value and refresh it let item: CacheItem = JSON.parse(ret); item = this._refreshItem(item, prefixedKey); return item.data; } } if (options && options.callback !== undefined) { const val: object | string | number | boolean = options.callback(); if (val !== null) { this.setItem(key, val, options); } return val; } return null; } catch (e) { logger.warn(`getItem failed! ${e}`); return null; } } /** * remove item from the cache * The cache will abort output a warning: * If error happened with browser storage * @param key - the key of the item */ public removeItem(key: string): void { logger.log(`Remove item: key is ${key}`); const prefixedKey: string = this.config.keyPrefix + key; if ( prefixedKey === this.config.keyPrefix || prefixedKey === this.cacheCurSizeKey ) { return; } try { const val: string | null = this.config.storage.getItem(prefixedKey); if (val) { this._removeItem(prefixedKey, JSON.parse(val).byteSize); } } catch (e) { logger.warn(`removeItem failed! ${e}`); } } /** * clear the entire cache * The cache will abort output a warning: * If error happened with browser storage */ public clear(): void { logger.log(`Clear Cache`); const keysToRemove: string[] = []; for (let i = 0; i < this.config.storage.length; i += 1) { const key = this.config.storage.key(i); if (key.indexOf(this.config.keyPrefix) === 0) { keysToRemove.push(key); } } try { for (let i = 0; i < keysToRemove.length; i += 1) { this.config.storage.removeItem(keysToRemove[i]); } } catch (e) { logger.warn(`clear failed! ${e}`); } } /** * Return all the keys in the cache. * * @return - all keys in the cache */ public getAllKeys(): string[] { const keys: string[] = []; for (let i = 0; i < this.config.storage.length; i += 1) { const key = this.config.storage.key(i); if ( key.indexOf(this.config.keyPrefix) === 0 && key !== this.cacheCurSizeKey ) { keys.push(key.substring(this.config.keyPrefix.length)); } } return keys; } /** * return the current size of the cache * * @return - current size of the cache */ public getCacheCurSize(): number { let ret: string | null = this.config.storage.getItem(this.cacheCurSizeKey); if (!ret) { this.config.storage.setItem(this.cacheCurSizeKey, '0'); ret = '0'; } return Number(ret); } /** * Return a new instance of cache with customized configuration. * @param config - the customized configuration * * @return - new instance of Cache */ public createInstance(config: CacheConfig): ICache { if (!config.keyPrefix || config.keyPrefix === defaultConfig.keyPrefix) { logger.error('invalid keyPrefix, setting keyPrefix with timeStamp'); config.keyPrefix = getCurrTime.toString(); } return new BrowserStorageCacheClass(config); } } export const BrowserStorageCache: ICache = new BrowserStorageCacheClass(); /** * @deprecated use named import */ export default BrowserStorageCache;