const {
deepAssignWithShallowMerge,
deepAssignWithOverwrite,
} = require('../shared/deepAssign');
/**
* @module browser/simpleStorage
*/
/**
* @callback SimpleStorageLoggerWarnFn
* @param {string} message The message to log the warning for.
* @parent module:browser/simpleStorage
*/
/**
* @typedef {Object} SimpleStorageLogger
* @property {?SimpleStorageLoggerWarnFn} warn Prints out a warning message. Either
* this or `warning` MUST be present.
* @property {?SimpleStorageLoggerWarnFn} warning Prints out a warning message. Either
* this or `warn` MUST be present.
* @parent module:browser/simpleStorage
*/
/**
* @typedef {'local' | 'session' | 'temp'} SimpleStorageStorageType
* @enum {string}
* @parent module:browser/simpleStorage
*/
/**
* @typedef {Object} SimpleStorageStorageTypes
* @property {SimpleStorageStorage} local The methods to work with `localStorage`.
* @property {SimpleStorageStorage} session The methods to work with `sessionStorage`.
* @property {SimpleStorageStorage} temp The methods to work with the _"temp
* storage"_.
* @parent module:browser/simpleStorage
*/
/**
* @typedef {Object} SimpleStorageStorageOptions
* @property {string} [name='simpleStorage']
* A reference name for the storage.
* @property {string} [key='simpleStorage']
* The key the class will use to store the data on the storage.
* @property {SimpleStorageStorageType[]} [typePriority=['local', 'session', 'temp']]
* The priority list of types of storage the service will try to use when initialized.
* @parent module:browser/simpleStorage
*/
/**
* A utility type for all the differents dictionary {@link SimpleStorage} manages.
*
* @typedef {Object.<string, any>} SimpleStorageDictionary
* @parent module:browser/simpleStorage
*/
/**
* @typedef {Object} SimpleStorageEntriesOptions
* @property {boolean} [enabled=false] Whether or not to use the entries
* functionality. Enabling it means
* that all the _"xxxEntry"_ methods
* will be available and that, when
* deleted or resetted, the storage
* will become an empty object.
* @property {number} [expiration=3600] The amount of seconds relative to
* the current time that needs to pass
* in order to consider an entry
* expired.
* @property {boolean} [deleteExpired=true] Whether or not to delete expired
* entries (both when loading the
* storage and when trying to access
* the entries).
* @property {boolean} [saveWhenDeletingExpired=true] Whether or not to sync the storage
* after deleting an expired entry.
* @parent module:browser/simpleStorage
*/
/**
* @typedef {Object} SimpleStorageOptions
* @property {boolean} [initialize=true]
* Whether or not to initialize the service right from the constructor.
* It means that it will validate the storage, check for existing data and sync it on the
* class. This can be disabled in case you need to do something between the constructor
* and the initialization.
* @property {Window} [window]
* The `window`/`global` object the class will use in order to access `localStorage` and
* `sessionStorage`.
* @property {?SimpleStorageLogger} [logger]
* A custom logger to print out the warnings when the class needs to do a fallback to a
* different storage type.
* @property {SimpleStorageStorageOptions} [storage]
* These are all the options related to the storage itself: The type, the name and the
* key.
* @property {SimpleStorageEntriesOptions} [entries]
* These are the options for customizing the way the service works with entries. By
* default, the class saves any kind of object on the storage,
* but by using entries you can access them by name and even define expiration time so
* they'll be removed after a while.
* @property {SimpleStorageDictionary} [tempStorage={}]
* The `tempStorage` is the storage the class uses when none of the others are available.
* Is just a simple object, so when the class gets destroyed (browser refreshes the page),
* the data goes away.
* @parent module:browser/simpleStorage
*/
/**
* @callback SimpleStorageStorageAvailableMethod
* @param {string} [fallbackFrom] If the storage is being used as a fallback from another
* one that is not available, this parameter will have its
* name.
* @returns {boolean} Whether or not the storage is available.
* @parent module:browser/simpleStorage
*/
/**
* @callback SimpleStorageStorageGetMethod
* @param {string} key The key used by the class to save data on the storage.
* @returns {SimpleStorageDictionary} The contents from the storage.
* @parent module:browser/simpleStorage
*/
/**
* @callback SimpleStorageStorageSetMethod
* @param {string} key The key used by the class to save data on the storage.
* @param {Object} value The data to save on the storage.
* @parent module:browser/simpleStorage
*/
/**
* @callback SimpleStorageStorageDeleteMethod
* @param {string} key The key used by the class to save data on the storage.
* @parent module:browser/simpleStorage
*/
/**
* @typedef {Object} SimpleStorageStorage
* @property {string} name
* The name of the storage.
* @property {SimpleStorageStorageAvailableMethod} isAvailable
* The method to check if the storage can be used or not.
* @property {SimpleStorageStorageGetMethod} get
* The method used to read from the storage.
* @property {SimpleStorageStorageSetMethod} set
* The method used to write on the storage.
* @property {SimpleStorageStorageDeleteMethod} delete
* The method used to delete data from the storage.
* @parent module:browser/simpleStorage
*/
/**
* @typedef {Object} SimpleStorageEntry
* @property {number} time The timestamp of when the entry was first created.
* @property {Object} value The actual data for the entry.
* @parent module:browser/simpleStorage
*/
/**
* An abstract class allows you to build services that relay on browser storage
* (session/local)
* and simplifies the way you work it You can specify the storage type you want to use,
* the format in which you want to handle the data and even expiration time for it.
*
* @abstract
* @parent module:browser/simpleStorage
* @tutorial simpleStorage
*/
class SimpleStorage {
/**
* @param {Partial<SimpleStorageOptions>} [options={}]
* The options to customize the class.
* @throws {Error}
* If instantiated without extending it.
*/
constructor(options = {}) {
// Validate that it's being extended.
if (new.target === SimpleStorage) {
throw new TypeError(
"SimpleStorage is an abstract class, it can't be instantiated directly",
);
}
/**
* These are the options/settings the class uses in order to work the with the storage
* and the data.
*
* @type {SimpleStorageOptions}
* @access protected
*/
this._options = this._mergeOptions(
{
window,
initialize: true,
storage: {
name: 'simpleStorage',
key: 'simpleStorage',
typePriority: ['local', 'session', 'temp'],
},
entries: {
enabled: false,
expiration: 3600,
deleteExpired: true,
saveWhenDeletingExpired: true,
},
logger: null,
tempStorage: {},
},
options,
);
/**
* A dictionary with the storage types the class supports.
*
* @type {SimpleStorageStorageTypes}
* @access protected
*/
this._storageTypes = {
local: {
name: 'localStorage',
isAvailable: this._isLocalStorageAvailable.bind(this),
get: this._getFromLocalStorage.bind(this),
set: this._setOnLocalStorage.bind(this),
delete: this._deleteFromLocalStorage.bind(this),
},
session: {
name: 'sessionStorage',
isAvailable: this._isSessionStorageAvailable.bind(this),
get: this._getFromSessionStorage.bind(this),
set: this._setOnSessionStorage.bind(this),
delete: this._deleteFromSessionStorage.bind(this),
},
temp: {
name: 'tempStorage',
isAvailable: this._isTempStorageAvailable.bind(this),
get: this._getFromTempStorage.bind(this),
set: this._setOnTempStorage.bind(this),
delete: this._deleteFromTempStorage.bind(this),
},
};
/**
* Once the class is initialized, this property will hold a reference to the
* {@link SimpleStorageStorage} being used.
*
* @type {?SimpleStorageStorage}
* @access protected
*/
this._storage = null;
/**
* This is the object/dictionary the class will use to sync the content of the
* storage. That way you won't need to write/read/parse from the storage every time
* you need to do something.
*
* @type {SimpleStorageDictionary}
* @access protected
*/
this._data = {};
// Initialize the class if necessary.
if (this._options.initialize) {
this._initialize();
}
}
/**
* Adds a new entry to the class data, and if `save` is used, saves it into the storage.
*
* @param {string} key The entry key.
* @param {Object | Promise} value The entry value, or a {@link Promise} that
* resolves into the value.
* @param {boolean} [save=true] Whether or not the class should save the data
* into the storage.
* @returns {Object | Promise} If `value` is an {@link Object}, it will return the same
* object; but if `value` is a {@link Promise}, it will
* return the _"promise chain"_.
* @access protected
*/
_addEntry(key, value, save = true) {
return this._isPromise(value)
? value.then((realValue) => this._addResolvedEntry(key, realValue, save))
: this._addResolvedEntry(key, value, save);
}
/**
* This is the real method behind `_addEntry`. It Adds a new entry to the class data
* and, if `save` is used, it also saves it into the storage.
* The reason that there are two methods for this is, is because `_addEntry` can receive
* a {@link Promise}, and in that case, this method gets called after it gets resolved.
*
* @param {string} key The entry key.
* @param {Object} value The entry value.
* @param {boolean} save Whether or not the class should save the data into the
* storage.
* @returns {Object} The same data that was saved.
* @access protected
*/
_addResolvedEntry(key, value, save) {
this._data[key] = {
time: this._now(),
value: deepAssignWithShallowMerge(value),
};
if (save) {
this._save();
}
return value;
}
/**
* Deletes the class data from the storage.
*
* @param {boolean} [reset=true] Whether or not to reset the data to the initial data
* (`_getInitialData`), if entries area disabled, or to
* an empty object, if they are enabled.
* @access protected
*/
_delete(reset = true) {
this._storage.delete(this._options.storage.key);
if (reset) {
this._setData(this._getInitialData(), false);
} else {
this._setData({}, false);
}
}
/**
* Deletes an entry from the class data, and if `save` is used, the changes will be
* saved on the storage.
*
* @param {string} key The entry key.
* @param {boolean} [save=true] Whether or not the class should save the data into the
* storage after deleting the entry.
* @returns {boolean} Whether or not the entry was deleted.
* @access protected
*/
_deleteEntry(key, save = true) {
const exists = this._hasEntry(key);
if (exists) {
delete this._data[key];
if (save) {
this._save();
}
}
return exists;
}
/**
* Filters out a dictionary of entries by checking if they expired or not.
*
* @param {Object} entries A dictionary of key-value, where the value is a
* {@link SimpleStorageEntry}.
* @param {number} expiration The amount of seconds that need to have passed in order
* to consider an entry expired.
* @returns {Object} A new dictionary without the expired entries.
* @access protected
*/
_deleteExpiredEntries(entries, expiration) {
const result = {};
const now = this._now();
Object.keys(entries).forEach((key) => {
const entry = entries[key];
if (now - entry.time < expiration) {
result[key] = entry;
}
});
return result;
}
/**
* Deletes an object from the `localStorage`.
*
* @param {string} key The object key.
* @access protected
*/
_deleteFromLocalStorage(key) {
delete this._options.window.localStorage[key];
}
/**
* Deletes an object from the `sessionStorage`.
*
* @param {string} key The object key.
* @access protected
*/
_deleteFromSessionStorage(key) {
delete this._options.window.sessionStorage[key];
}
/**
* Deletes an object from the _"temp storage"_.
*
* @param {string} key The object key.
* @access protected
*/
_deleteFromTempStorage(key) {
delete this._options.tempStorage[key];
}
/**
* Gets the data the class saves on the storage.
*
* @returns {Object}
* @access protected
*/
_getData() {
return this._data;
}
/**
* Gets an entry from the storage dictionary.
*
* @param {string} key The entry key.
* @returns {?SimpleStorageEntry} Whatever is on the storage, or `null`.
* @throws {Error} If entries are not enabled.
* @access protected
*/
_getEntry(key) {
const { entries } = this._options;
// Validate if the feature is enabled and fail with an error if it isn't.
if (!entries.enabled) {
throw new Error('Entries are not enabled for this storage');
}
// Get the entry from the data reference.
let entry = this._data[key];
// If an entry was found and the setting to delete entries when expired is enabled...
if (entry && entries.deleteExpired) {
// ...validate if the entry is expired.
({ entry } = this._deleteExpiredEntries({ entry }, entries.expiration));
// ... and if the entry is expired, delete it.
if (!entry) {
this._deleteEntry(key, entries.saveWhenDeletingExpired);
}
}
// Return either the entry it found or `null`.
return entry || null;
}
/**
* Gets the value of an entry.
*
* @param {string} key The entry key.
* @returns {?SimpleStorageDictionary}
* @access protected
*/
_getEntryValue(key) {
const entry = this._getEntry(key);
return entry ? entry.value : entry;
}
/**
* Gets an object from `localStorage`.
*
* @param {string} key The key used to save the object.
* @returns {?SimpleStorageDictionary}
* @access protected
*/
_getFromLocalStorage(key) {
const value = this._options.window.localStorage[key];
return value ? JSON.parse(value) : null;
}
/**
* Gets an object from `sessionStorage`.
*
* @param {string} key The key used to save the object.
* @returns {?SimpleStorageDictionary}
* @access protected
*/
_getFromSessionStorage(key) {
const value = this._options.window.sessionStorage[key];
return value ? JSON.parse(value) : null;
}
/**
* Gets an object from the _"temp storage"_.
*
* @param {string} key The key used to save the object.
* @returns {?SimpleStorageDictionary}
* @access protected
*/
_getFromTempStorage(key) {
return this._options.tempStorage[key];
}
/**
* This method is called when the storage is deleted or resetted and if entries are
* disabled.
* It can be used to define the initial value of the data the class saves on the
* storage.
*
* @returns {SimpleStorageDictionary}
* @access protected
*/
_getInitialData() {
return {};
}
/**
* Checks whether an entry exists or not.
*
* @param {string} key The entry key.
* @returns {boolean}
* @access protected
*/
_hasEntry(key) {
return !!this._data[key];
}
/**
* This method _"initializes" the class by validating custom options, loading the
* reference for the required storage and synchronizing the data with the storage.
*
* @access protected
*/
_initialize() {
this._validateOptions();
this._storage = this._initializeStorage();
this._data = this._initializeStorageData();
}
/**
* This method checks the list of priorities from the `storage.typePriority` option and
* tries to find the first available storage.
*
* @returns {SimpleStorageStorage}
* @throws {Error} If none of the storage options are available.
* @access protected
*/
_initializeStorage() {
let previousType;
const found = this._options.storage.typePriority
.filter((storageType) => !!this._storageTypes[storageType])
.find((storageType) => {
const storage = this._storageTypes[storageType];
const fallbackFrom = previousType ? storage.name : '';
previousType = storage;
return storage.isAvailable(fallbackFrom);
});
if (!found) {
throw new Error('None of the specified storage types are available');
}
return this._storageTypes[found];
}
/**
* Initializes the data on the class and if needed, on the storage. It first tries to
* load existing data from the storage, if there's nothing, it just sets an initial
* stage; but if there was something on the storage, and entries are enabled, it will
* try (if also enabled)
* to delete expired entries.
*
* @returns {Object}
* @access protected
*/
_initializeStorageData() {
const { storage, entries } = this._options;
let data = this._storage.get(storage.key) || null;
if (data && entries.enabled && entries.deleteExpired) {
data = this._deleteExpiredEntries(data, entries.expiration);
} else if (!data) {
data = entries.enabled ? {} : this._getInitialData();
this._storage.set(storage.key, data);
}
return data;
}
/**
* Checks whether `localStorage` is available or not.
*
* @param {string} [fallbackFrom] In case it's being used as a fallback, this will be
* the name of the storage that wasn't available.
* @returns {boolean}
* @access protected
*/
_isLocalStorageAvailable(fallbackFrom) {
if (fallbackFrom) {
this._warnStorageFallback(fallbackFrom, 'localStorage');
}
return !!this._options.window.localStorage;
}
/**
* Checks whether an object is a Promise or not.
*
* @param {*} obj The object to test.
* @returns {boolean}
* @access protected
*/
_isPromise(obj) {
return (
typeof obj === 'object' &&
typeof obj.then === 'function' &&
typeof obj.catch === 'function'
);
}
/**
* Checks whether `sessionStorage` is available or not.
*
* @param {string} [fallbackFrom] In case it's being used as a fallback, this will be
* the name of the storage that wasn't available.
* @returns {boolean}
* @access protected
*/
_isSessionStorageAvailable(fallbackFrom) {
if (fallbackFrom) {
this._warnStorageFallback(fallbackFrom, 'sessionStorage');
}
return !!this._options.window.sessionStorage;
}
/**
* This method is just here to comply with the {@link SimpleStorageStorage}
* _"interface"_ as the temp storage is always available.
*
* @param {string} [fallbackFrom] In case it's being used as a fallback, this will be
* the name of the storage that wasn't available.
* @returns {boolean}
* @access protected
*/
_isTempStorageAvailable(fallbackFrom) {
if (fallbackFrom) {
this._warnStorageFallback(fallbackFrom, 'tempStorage');
}
return true;
}
/**
* Merges the class default options with the custom ones that can be sent to the
* constructor.
* The reason there's a method for this is because of a specific (edgy) use case:
* `tempStorage`
* can be a Proxy, and a Proxy without defined keys stops working after an
* `Object.assign`/spread.
*
* @param {SimpleStorageOptions} defaults The class default options.
* @param {SimpleStorageOptions} custom The custom options sent to the constructor.
* @returns {SimpleStorageOptions}
* @access protected
*/
_mergeOptions(defaults, custom) {
const { tempStorage } = custom;
const options = deepAssignWithOverwrite(defaults, custom);
if (tempStorage) {
options.tempStorage = tempStorage;
}
return options;
}
/**
* Helper method to get the current timestamp in seconds.
*
* @returns {number}
* @access protected
*/
_now() {
return Math.floor(Date.now() / 1000);
}
/**
* Resets the data on the class; If entries are enabled, the data will become an empty
* {@link object}; otherwise, it will call {@link this#_getInitialData}.
*
* @param {boolean} [save=true] Whether or not the class should save the data into the
* storage.
* @returns {Object}
* @access protected
*/
_resetData(save = true) {
const data = this._options.entries.enabled ? {} : this._getInitialData();
return this._setData(data, save);
}
/**
* Saves the data from the class into the storage.
*
* @access protected
*/
_save() {
this._storage.set(this._options.storage.key, this._data);
}
/**
* Overwrites the data reference the class has and, if `save` is used, it also saves it
* into the storage.
*
* @param {Object | Promise} data The new data, or a {@link Promise} that
* resolves into the new data.
* @param {boolean} [save=true] Whether or not the class should save the data
* into the storage.
* @returns {Object | Promise} If `data` is an {@link object}, it will return the same
* object; but if `data` is a {@link Promise}, it will
* return the _"promise chain"_.
* @access protected
*/
_setData(data, save = true) {
return this._isPromise(data)
? data.then((realData) => this._setResolvedData(realData, save))
: this._setResolvedData(data, save);
}
/**
* Sets an object into the `localStorage`.
*
* @param {string} key The object key.
* @param {Object} value The object to save.
* @access protected
*/
_setOnLocalStorage(key, value) {
this._options.window.localStorage[key] = JSON.stringify(value);
}
/**
* Sets an object into the `sessionStorage`.
*
* @param {string} key The object key.
* @param {Object} value The object to save.
* @access protected
*/
_setOnSessionStorage(key, value) {
this._options.window.sessionStorage[key] = JSON.stringify(value);
}
/**
* Sets an object into the _"temp storage"_.
*
* @param {string} key The object key.
* @param {Object} value The object to save.
* @access protected
*/
_setOnTempStorage(key, value) {
this._options.tempStorage[key] = value;
}
/**
* This is the real method behind `_setData`. It overwrites the data reference the class
* has and, if `save` is used, it also saves it into the storage.
* The reason that there are two methods for this is, is because `_setData` can receive
* a {@link Promise}, and in that case, this method gets called after it gets resolved.
*
* @param {Object} data The new data.
* @param {boolean} save Whether or not the class should save the data into the
* storage.
* @returns {Object} The same data that was saved.
* @access protected
*/
_setResolvedData(data, save) {
this._data = deepAssignWithShallowMerge(data);
if (save) {
this._save();
}
return data;
}
/**
* Validates the class options before loading the storage and the data.
*
* @throws {Error} If either `storage.name` or `storage.key` are missing from the
* options.
* @throws {Error} If the options have a custom logger but it doesn't have `warn` nor
* `warning`
* methods.
* @access protected
*/
_validateOptions() {
const { storage, logger } = this._options;
const missing = ['name', 'key'].find((key) => typeof storage[key] !== 'string');
if (missing) {
throw new Error(`Missing required configuration setting: ${missing}`);
}
if (
logger &&
typeof logger.warn !== 'function' &&
typeof logger.warning !== 'function'
) {
throw new Error('The logger must implement a `warn` or `warning` method');
}
}
/**
* Prints out a warning message. The method will first check if there's a custom logger
* (from the class options), otherwise, it will fallback to the `console` on the
* `window` option.
*
* @param {string} message The message to print out.
* @access protected
*/
_warn(message) {
const { logger } = this._options;
if (logger) {
if (logger.warning) {
logger.warning(message);
} else {
logger.warn(message);
}
} else {
// eslint-disable-next-line no-console
this._options.window.console.warn(message);
}
}
/**
* Prints out a message saying that the class is doing a fallback from a storage to
* another one.
*
* @param {string} from The name of the storage that's not available.
* @param {string} to The name of the storage that will be used instead.
* @access protected
*/
_warnStorageFallback(from, to) {
this._warn(`${from} is not available; switching to ${to}`);
}
}
module.exports = SimpleStorage;