src/Persistence.js
const instances = [];
const DEFAULT_PUBLIC_PATH = '/';
if (!window.__persistenceInitialized) {
window.__persistenceInitialized = true;
window.addEventListener('beforeunload', function persistAll() {
instances.forEach(instance => {
if (instance.enabled) {
try {
instance.save();
} catch (error) {
console.warn('[Persistence] Failed saving', {
error,
instance
});
}
}
});
});
}
/**
* A wrapper for persisting data to `localStorage` or `sessionStorage`.
* You can create multiple instances but you must provide unique names;
*
* The API resembles the one from the web storage objects: `setItem(key, vaue)`, `getItem(key)`, `removeItem(key)`, but there are also shorter aliases: `set()`, `get()` and `remove()`.
*
* In order to avoid expensive JSON serialization/deserialization by interacting with the storage backend, the data is held in memory (in a simple object).
* All operations operate on that plain object. An event listener is registered for the `window.beforeunload` event, and only then we will serialize the data to an actual JSON string.
* The string is then persisted to the backend storage, e.g. `window.localStorage` (default).
*
* @example
* const storage = new Persistence('storage');
* storage.setItem('started', new Date());
* console.log(storage.getItem('started'));
*
* @author jovica.aleksic@xailabs.de
*/
class Persistence {
//
// Static API
//
//--------------------------------------------------------------------
/**
* Whether to log details to the console when certain static methods are called.
* @type {Boolean}
*/
static logging = false;
/**
* An array containing all instances.
* _NOTE: This is a read-only value. You can not mutate the actual instances array._
* @type {Array}
*/
static get instances() {
return [...instances];
}
/**
* Returns an already existing instance for a given name or creates a new instance and returns it.
* Note that all constructor options are supported here as well.
*
* @param {Object} [options] - Options object
* @param {String} [options.publicPath = '/'] - The path where this app is deployed within the current origin
* @return {Object} - The found or created `Persistence` object
*/
static connect(name, options = {}) {
const instance = Persistence.get(`[${options.publicPath || DEFAULT_PUBLIC_PATH}]${name}`);
if (instance) {
if (options) {
instance.init(options);
}
return instance;
}
return new Persistence(name, options);
}
/* eslint-disable */
/**
* Gets an instance by its full name.
* The full name includes the `publicPath` in order to avoid collisions in cases where multiple apps operate within the same origin.
*
* For example, if you created an instance via `new Persistence('foo')`, you could access it via `Persistence.get('[/]foo')`.
* On the other hand, you might also have a debuggable version deployed on the same origin at `/dev`. (You'd probably use some kind of config value for `publicPath` in such cases).
* So, if you had created the instance via `new Persistence('foo', {publicPath: '/dev'})`, you would access it via `Persistence.get('[/dev]foo')`.
*
* _NOTE: If you do not want to deal with full names and `publicPath` values, use `Persistence.find(name)` instead._
* _NOTE: If you need to get all instances of the origin, use `Persistence.filter(name)` instead._
*
* @see {@link Persistence.find}
* @see {@link Persistence.filter}
*
* @param {String} name - the full instance name
* @return {Object} - The found `Persistence` object or `undefined`
*/
/* eslint-enable */
static get(name) {
return instances.find(instance => instance.name === name);
}
/**
* Finds a single instance by partial name match.
*
* @example
* const instance = new Persistence('config', {publicPath: '/app-one/dev'});
* console.log(Persistence.find('conf')) // {logging: false, save: ƒ, name: "[/app-one/dev]config", backend: Storage, defaultData: {…}, …}
*
* @param {string|RegExp} name - A string or regular expression that will be matched against instance names
* @return {Object} - The found `Persistence` object or `undefined`
*/
static find(name) {
return instances.find(instance => instance.name.match(name));
}
/**
* Filters all instances by partial name match.
*
* @param {string|RegExp} name - A string or regular expression that will be matched against instance names
* @return {Array} - An array of matching `Persistence` objects. Might be empty.
*/
static filter(name) {
return instances.filter(instance => instance.name.match(name));
}
/**
* Returns an object containing all instances as values.
* The instance names are used as keys. If you need an array of instances and don't care about their names, use `Object.values(Persistence.getAll())`.
* @return {Object} - A key/value object where each key is an instance name and each value is an instance.
*/
static getAll() {
return instances.reduce((result, instance) => {
result[instance.name] = instance;
return result;
}, {});
}
/**
* Clears the data of all instances.
* @param {Object} [options] - Options object
* @param {Array} [options.only] - An array of names. If provided, **only** instances whose names match partially will be cleared.
* @param {Array} [options.not] - An array of names. If provided, instances whose names match partially will **NOT** be cleared.
*/
static clearAll(options = {}) {
const onlyIncluded = instance => !options.only || options.only.find(name => instance.name.match(name));
const notExcluded = instance => !options.not || !options.not.find(name => instance.name.match(name));
const cleared = instances
.filter(onlyIncluded)
.filter(notExcluded)
.map(instance => {
instance.clear();
return instance;
});
const skipped = instances.filter(instance => cleared.indexOf(instance) === -1);
Persistence.logging &&
console.info(
'[Persistence]',
cleared.length ? `\n\tcleared: ${cleared.map(instance => instance.name)}` : '',
skipped.length ? `\n\tskipped: ${skipped.map(instance => instance.name)}` : ''
);
}
/**
* Retrieves the size of the occupied space for all instances.
* The value is a number of characters and roughly approximates bytes.
* TODO: proper byte-size conversion
*/
static getSize() {
return instances.reduce((result, instance) => {
return result + instance.getSize();
}, 0);
}
//
// Instance API
//
//--------------------------------------------------------------------
/**
* Whether to log details to the console when certain instance methods are called.
* @type {Boolean}
*/
logging = false;
/* eslint-disable */
/**
* Creates a new Persistence object.
* A Persistence stores its data in a simple object (in memory) and persists the data as a JSON string to web storage backend (e.g. `window.localStorage`) before the window unloads, using the instance `name` as the actual key in the web storage backend.
* When a new instance is created, it looks for previously stored data in the web storage backend, and parses it back to a data object for runtime usage.
*
* This means that you can set and get values without an performance penalty, JSON conversion only happens at startup and before unload.
*
* You can specify a name to enable multiple apps on the same origin without collisions in data using the `publicPath` option.
* (Consider that localStorage is shared per origin (protocol, host, port), so if you want to have two _instances in /a and /b, you can use those paths as names for the Persistence)
*
* @param {String} name - A unique name for this backend object.
* @param {Object} options -
* @param {Object} options.data - An initial data object for this instance
* @param {Object} options.backend - The backend object to use as backend. Should expose `getItem`, `getItem` and `removeItem` function. Defaults to `window.localStorage`.
* @param {Boolean} options.logging - Whether to enable to logging for this instance
* @param {Boolean} options.autoEnable - Whether this instance should be enabled. Defaults to true.
* @param {String} [options.publicPath = '/'] - The path where this app is deployed within the current origin
*/
/* eslint-enable */
constructor(name, options = {}) {
this.name = `[${options.publicPath || DEFAULT_PUBLIC_PATH}]${name}`;
this.save = this.save.bind(this);
this.init(options);
instances.push(this);
}
/**
* @private
*/
init({ backend = window.localStorage, autoEnable = true, data = {}, logging = this.logging } = {}) {
this.backend = backend;
this.logging = logging;
this.defaultData = this.defaultData || { ...data };
this.data = { ...this.defaultData, ...this.data, ...data };
if (!this.backend.getItem(`${this.name}:disabled`) && autoEnable) {
const savedData = this.backend.getItem(this.name);
if (savedData) {
try {
this.data = { ...this.data, ...JSON.parse(savedData) };
} catch (error) {
console.warn('[Persistence] failed parsing saved data', {
error,
savedData
});
}
}
this.enable();
}
}
/**
* Checks if a property exists.
* Note: `true` will be returned as long as the key is found on the data object, even if the value itself is empty.
*
* @param {String} key - The property name to check for existence.
* @return {Boolean} - `true` if `key` was found
*/
has(key) {
this.data.hasOwnProperty(key);
}
/**
* Alias for `setItem(key, value)`
* @see {@link Persistence#setItem}
* @param {String|Object} key - The key for the value
* @param {any} [value] - When `key` is a string: the value, otherwise: the `autoSave` flag
* @param {Boolean} [autoSave] - When `key` is a string: Whether to immediatly write to the backend.
*/
set(key, value, autoSave) {
return this.setItem(key, value, autoSave);
}
/**
* Alias for `getItem(key)`
* @see {@link Persistence#getItem}
* @param {String} key - The key to retrieve a value for
* @return {any} - The value for `key`
*/
get(key) {
return this.getItem(key);
}
/**
* Alias for `removeItem(key)`
* @see {@link Persistence#removeItem}
* @param {String} key - The key to delete
* @param {Boolean} autoSave - Whether to write to backend. Defaults to false
*/
remove(key, autoSave) {
return this.removeItem(key, autoSave);
}
/**
* Retrieves a value from the backend data.
* - Booleans, numbers, undefined and null will be parsed/casted to proper JS objects/values
* - All other values will be returned as strings
*
* Note: This does not read from the backend directly, but from the data object instead (which was initially parsed from the backend values)
*
* @param {String} key - The key to retrieve a value for
* @return {any} - The value for `key`
*/
getItem(key) {
if (key === undefined) {
this.logging && console.log('get', { key, result: this.data });
return this.data;
}
const getValue = () => {
const value = this.data[key];
if (value !== undefined) {
if (value === 'true' || value === true) {
return true;
}
if (value === 'false' || value === false) {
return false;
}
if (value === 'undefined') {
return undefined;
}
if (value === 'null' || value === null) {
return null;
}
if (value === '') {
return '';
}
if (Array.isArray(value)) {
// explicitely check for array!
// otherwise, an array containing a single numeric value will pass as a number in the next isNaN check
// because javascript is an idiot sometimes..: `isNaN(['1']) === false` due to casting/auto-boxing `['1'] -> '1' -> 1 > numeric`
return value;
}
if (!isNaN(value)) {
return Number(value);
}
return value || null;
}
return value;
};
const result = getValue();
this.logging && console.log('get', { key, result });
return result;
}
/**
* Sets a key/value to the data object.
* If the first argument is not a string but an object, the signature changes to `(values, autoSave)` and `setItemValues` will be used internally.
*
* @param {String|Object} key - The key for the value
* @param {any} [value] - When `key` is a string: the value, otherwise: the `autoSave` flag
* @param {Boolean} [autoSave] - When `key` is a string: Whether to immediatly write to the backend.
*/
setItem(key, value, autoSave) {
if (typeof key !== 'string') {
return this.setItemValues(key, value);
}
this.data[key] = value;
this.logging && console.log('set', key, value);
if (autoSave === true) {
this.save();
}
}
/**
* Deletes a key and its value from the data object.
* Does not write to this.backend automatically!
*
* @param {String} key - The key to delete
* @param {Boolean} autoSave - Whether to write to backend. Defaults to false
*/
removeItem(key, autoSave) {
delete this.data[key];
this.logging && console.log('remove', key);
if (autoSave === true) {
this.save();
}
}
/**
* Sets all values of a given object at once.
* Calls `set(key, value)` for each key of the object.
*
* @param {Object} values - An object with keys and values
* @param {Boolean} autoSave - Whether to write to the storage backend right away.
*/
setItemValues(values, autoSave) {
Object.keys(values).forEach(key => {
this.set(key, values[key]);
});
if (autoSave) {
this.save();
}
}
/**
* Persistently enables this instance.
* Setters will change values, save will write to the backend, save() will be called before unload.
*/
enable() {
this.enabled = true;
this.backend.removeItem(`${this.name}:disabled`);
}
/**
* Persistently disables this instance.
* The data object will be cleared, setters will not change values anymore, save() will not write to the backend, and it will not be called before unload.
* To only disable the functionality without flushing the data, pass `false` as argument.
*
* The disabled state will be persisted to backend: The instance remains inactive until `enable()` is called, even across browser reload.
*
* @param {Boolean} autoClear - Whether to remove the data as well. Defaults to true.
*/
disable(autoClear = true) {
if (autoClear) {
this.clear();
}
this.enabled = false;
this.backend.setItem(`${this.name}:disabled`, true);
}
/**
* Empties the data object and the persisted backend values.
*/
clear() {
this.logging && console.log('clear', { ...this.data });
this.data = { ...this.defaultData };
this.save();
}
/**
* Writes the data object to backend
*/
save() {
try {
this.logging && console.log('save', this.name, { ...this.data }, JSON.stringify(this.data));
const data = JSON.stringify(this.data);
this.backend.setItem(this.name, data);
} catch (error) {
console.warn(error);
}
}
/**
* Gets the size of this storage.
* Seems to approximate bytes.
* @see http://stackoverflow.com/a/34245594/368254
* @return {number} - The number of characters used to store the stringified data.
*/
getSize() {
var sum = 0;
const keys = Object.keys(this.data);
const data = this.data;
for (var i = 0; i < keys.length; ++i) {
var key = keys[i];
var value = JSON.stringify(data[key]);
sum += key.length + value.length;
}
return sum;
}
}
export default Persistence;
window.Persistence = Persistence;