LocalStorage.js

import { MODULE_NAME } from './consts';
import destroy from './destroy';
import Events from './Events';

const EVENT_STORAGE = "storage";

/**
 * `StorageEvent` defines the events dispatched by `LocalStorage`.
 * 
 * @see {LocalStorage}
 * @typedef StorageEvent
 * @type {Object}
 * @property {string|null} key The storage item key.
 * @property {Object|string|null} oldValue The old value.
 * @property {Object|string|null} newValue The new value.
 * @property {boolean} cleared `true` if the storage was cleared.
 * @property {boolean} created `true` if this event fired due to `key` being created in storage.
 * @property {boolean} deleted `true` if this event fired due to `key` being removed from storage.
 * @property {boolean} modified `true` if this event fired due to `key` being modified in storage.
 */

/**
 * Will be `true` if the features needed by this class are available in the environment; `false` otherwise.
 * 
 * @ignore
 */
const ENABLED = true
    && window 
    && window.localStorage && window.localStorage.setItem && window.localStorage.getItem && window.localStorage.removeItem
    && window.addEventListener && window.removeEventListener;

/**
 * `LocalStorage` provides access to `window.localStorage`.  
 * 
 * > *Support*  
 * >> `LocalStorage` only works when `window.localStorage` is an `Object` with the approproate interface.  In environments where
 * `window.localStorage` does not exist or does not have the correct interface all methods on `LocalStorage` are no-ops.  
 * 
 * > *Automatic Encoding*  
 * >> Values set or retrieved from storage are automatically JSON encoded and decoded.  A value that can not be JSON decoded is
 * returned as-is when retrieving said value or dispatching events.
 * 
 * > *Global or Prefixed*  
 * >> Access to storage can be *global* or *prefixed* by setting `opts.prefix` to an empty string or a non-empty string respectively.  
 * 
 * >> *Global*  
 * >>> When *global* it is possible for items set in storage to overwrite or replace items set by other packages or modules.  Events are not
 * filtered and you will receive `StorageEvent`s for items you may not care to receive.
 * 
 * >> *Prefixed*  
 * >>> When *prefixed* items only overwrite or replace items set with the same prefix.  Events for items are filtered by prefix and you
 * will only receive events for items with a matching prefix.  
 * >>> Prefixes are automatically prepended or stripped from item keys as appropriate; you only need to set the prefix when creating
 * an instance of `LocalStorage`.  
 * 
 * > *Predictable Prefixes*  
 * >> `LocalStorage` is used to send data across browser tabs; therefore when using prefixes each tab needs to use the same
 * prefix to only receive events it cares about.  
 * >> UUIDs or other unique identifiers will not make good prefixes unless you implement a mechanism to share those values
 * with every browser tab of your application.  
 * 
 * > *Examples*  
 * >> Consider the following event handler for all of the examples:  
 * >> ```js
 * >> const storageHandler = event => {
 * >>     console.log( 
 * >>         event.key, event.created, event.deleted, event.modified,
 * >>         event.oldValue, event.newValue
 * >>     );
 * >> }
 * >> ```
 * 
 * >> *Global*  
 * >>> ```js
 * >>> const storage = new LocalStorage();
 * >>> let unregister = storage.register( storageHandler );
 * >>>
 * >>> storage.set( 'message', 'Hello, World!' );
 * >>> // console: message, true, false, false, null, 'Hello, World!'
 * >>>
 * >>> storage.set( 'message', 'Good bye!' );
 * >>> // console: message, false, false, true, 'Hello, World!', 'Good bye!'
 * >>>
 * >>> unregister(); // Removes handler
 * >>>
 * >>> // Only events for keys === 'total' will fire.
 * >>> unregister = storage.registerItem( 'total', storageHandler );
 * >>>
 * >>> storage.set( 'message', 'Hello, World!' );
 * >>> storage.set( 'message', 'Good bye!' );
 * >>> // Neither of the above will create console output.
 * >>>
 * >>> storage.set( 'total', 42 );
 * >>> // console: total, true, false, false, null, 42
 * >>> ```
 * 
 * >> *Prefixed*  
 * >>> ```js
 * >>> const storage = new LocalStorage();
 * >>> const prefixed = new LocalStorage( { prefix : 'some-unique-value' } );
 * >>> let unregister = prefixed.register( storageHandler );
 * >>>
 * >>> storage.set( 'message', 'Hello, World!' );
 * >>> // No console output -- setting value on global storage does not
 * >>> // trigger events on prefixed storage.
 * >>>
 * >>> prefixed.set( 'someValue', 'important data' );
 * >>> // console: someValue, true, false, false, null, 'important data'
 * >>> // NB: Handlers on `storage` would fire as well with 
 * >>> //     key as 'some-unique-value' + 'someValue'
 * >>> ```
 * 
 * @class
 * @see {StorageEvent}
 */
class LocalStorage {
    /**
     * Creates a new `LocalStorage` instance.
     * 
     * @param {Object} opts Options for `LocalStorage`.
     * @param {string} opts.prefix If provided acts as a prefix for items set and retrieved in the storage;
     * can be used to prevent name collisions.
     */
    constructor( opts ) {
        opts = opts || {};
        let { prefix } = opts;
        prefix = typeof prefix === "string" ? prefix : "";
        this.props = {
            prefix,
            // `events` are handlers for every item or key in the storage.
            events : new Events(),
            // `itemEvents` are handlers for specific items or keys in the storage.
            itemEvents : new Events(),
        };
        this.funcs = {
            /**
             * `decode` decodes the values from storage.
             * 
             * @param {string} value The `value` of the storage item.
             * @returns {Object|string|null}
             */
            decode : value => {
                if( typeof value !== "string" ) {
                    return value;
                }
                // This package assumes values are JSON encoded.
                try {
                    let decoded = JSON.parse( value );
                    value = decoded;
                } catch( decodeError ) {
                    ; // Error decoding JSON - just use as a string.
                }
                return value;
            },

            /**
             * `handler` is our internal handler for storage events.
             */
            handler : storageEvent => {
                let { key, oldValue, newValue } = storageEvent;
                const cleared = key === null;
                const created = ! cleared && oldValue === null && newValue !== null;
                const deleted = cleared || oldValue !== null && newValue === null;
                const modified = ! cleared && ! created && oldValue !== newValue;
                let event = { key, oldValue, newValue, cleared, created, deleted, modified };
                if( key !== null && (prefix === "" || key.startsWith( prefix )) ) {
                    // This package assumes values are JSON encoded.
                    oldValue = this.funcs.decode( oldValue );
                    newValue = this.funcs.decode( newValue );
                    key = prefix !== "" ? key.replace( prefix, "" ) : key;
                    event = { ...event, key, oldValue, newValue };
                    this.props.events.trigger( EVENT_STORAGE, event );
                    this.props.itemEvents.trigger( key, event );
                } else if( key === null ) {
                    this.props.events.trigger( EVENT_STORAGE, event );
                }
            }
        }
        //
        if( ENABLED ) {
            window.addEventListener( "storage", this.funcs.handler );
        }
    }

    /**
     * `destroy` unregisters all handlers and frees internal members.  
     * 
     * It does not delete any of the data in local storage.
     * 
     * @method
     */
    destroy = () => {
        if( ENABLED ) {
            window.removeEventListener( "storage", this.funcs.handler );
        }
        //
        this.props.events.destroy();
        this.props.itemEvents.destroy();
        delete this.props;
        //
        destroy( this, MODULE_NAME, "LocalStorage" );
    }

    /**
     * `get` returns the storage value for `item`.
     * 
     * @method
     * @param {string} item The `item` to get.
     * @returns {Object|string}
     */
    get = ( item ) => {
        if( ENABLED ) {
            const { prefix } = this.props;
            item = window.localStorage.getItem( prefix + item );
            return this.funcs.decode( item );
        }
        return null;
    }

    /**
     * `register` registers an event `handler` for any change in the storage.  Changes to specific items
     * and clearing the storage will fire `handler`.
     * 
     * @method
     * @param {func} handler Handler with signature ` (StorageEvent) => {}`
     * @returns {func} An unregister function.
     */
    register = handler => {
        if( ENABLED ) {
            return this.props.events.register( EVENT_STORAGE, handler );
        }
        return () => null;
    }

    /**
     * `registerItem` registers an event `handler` for any change in storage for a specific storage item
     * or key.
     * 
     * @method
     * @param {string} item The `item` name or key.
     * @param {func} handler Handler with signature ` (StorageEvent) => {}`
     * @returns {func} An unregister function.
     */
    registerItem = ( item, handler ) => {
        if( ENABLED ) {
            return this.props.itemEvents.register( item, handler );
        }
        return () => null;
    }

    /**
     * `remove` removes the data in the named `item`.
     * 
     * @method
     * @param {string} item The `item` to remove.
     */
    remove = item => {
        if( ENABLED ) {
            const { prefix } = this.props;
            window.localStorage.removeItem( prefix + item );
        }
    }

    /**
     * `set` sets the storage `item` to `value`.  `value` will be JSON encoded.
     * 
     * @method
     * @param {string} item The `item` to set.
     * @param {Object|string} value The `value` to store.
     */
    set = ( item, value ) => {
        if( ENABLED ) {
            const { prefix } = this.props;
            window.localStorage.setItem( prefix + item, JSON.stringify( value ) );
        }
    }
}

export default LocalStorage;