Introduce base/storage to represent the Web Storage API and persistence-related customizations

This commit is contained in:
Lyubo Marinov
2018-02-02 15:13:26 -06:00
parent 83243d5980
commit d7dddb2509
16 changed files with 93 additions and 72 deletions
@@ -0,0 +1,169 @@
// @flow
import Logger from 'jitsi-meet-logger';
import md5 from 'js-md5';
const logger = Logger.getLogger(__filename);
/**
* The name of the localStorage store where the app persists its values to.
*/
const PERSISTED_STATE_NAME = 'jitsi-state';
/**
* The type of the name-config pairs stored in this reducer.
*/
declare type PersistencyConfigMap = { [name: string]: Object };
/**
* A registry to allow features to register their redux store subtree to be
* persisted and also handles the persistency calls too.
*/
class PersistenceRegistry {
_checksum: string;
_elements: PersistencyConfigMap;
/**
* Initializes a new {@ code PersistenceRegistry} instance.
*/
constructor() {
this._elements = {};
}
/**
* Returns the persisted redux state. This function takes the
* {@link #_elements} into account as we may have persisted something in the
* past that we don't want to retreive anymore. The next
* {@link #persistState} will remove those values.
*
* @returns {Object}
*/
getPersistedState() {
let filteredPersistedState = {};
let persistedState = window.localStorage.getItem(PERSISTED_STATE_NAME);
if (persistedState) {
try {
persistedState = JSON.parse(persistedState);
} catch (error) {
logger.error(
'Error parsing persisted state',
persistedState,
error);
persistedState = {};
}
filteredPersistedState
= this._getFilteredState(persistedState);
}
this._checksum = this._calculateChecksum(filteredPersistedState);
logger.info('redux state rehydrated as', filteredPersistedState);
return filteredPersistedState;
}
/**
* Initiates a persist operation, but its execution will depend on the
* current checksums (checks changes).
*
* @param {Object} state - The redux state.
* @returns {void}
*/
persistState(state: Object) {
const filteredState = this._getFilteredState(state);
const newCheckSum = this._calculateChecksum(filteredState);
if (newCheckSum !== this._checksum) {
try {
window.localStorage.setItem(
PERSISTED_STATE_NAME,
JSON.stringify(filteredState));
logger.info(
`redux state persisted. ${this._checksum} -> ${
newCheckSum}`);
this._checksum = newCheckSum;
} catch (error) {
logger.error('Error persisting redux state', error);
}
}
}
/**
* Registers a new subtree config to be used for the persistency.
*
* @param {string} name - The name of the subtree the config belongs to.
* @param {Object} config - The config object.
* @returns {void}
*/
register(name: string, config: Object) {
this._elements[name] = config;
}
/**
* Calculates the checksum of the current or the new values of the state.
*
* @private
* @param {Object} filteredState - The filtered/persisted redux state.
* @returns {string}
*/
_calculateChecksum(filteredState: Object) {
try {
return md5.hex(JSON.stringify(filteredState) || '');
} catch (error) {
logger.error(
'Error calculating checksum for state',
filteredState,
error);
return '';
}
}
/**
* Prepares a filtered state from the actual or the persisted redux state,
* based on this registry.
*
* @private
* @param {Object} state - The actual or persisted redux state.
* @returns {Object}
*/
_getFilteredState(state: Object) {
const filteredState = {};
for (const name of Object.keys(this._elements)) {
if (state[name]) {
filteredState[name]
= this._getFilteredSubtree(
state[name],
this._elements[name]);
}
}
return filteredState;
}
/**
* Prepares a filtered subtree based on the config for persisting or for
* retrieval.
*
* @private
* @param {Object} subtree - The redux state subtree.
* @param {Object} subtreeConfig - The related config.
* @returns {Object}
*/
_getFilteredSubtree(subtree, subtreeConfig) {
const filteredSubtree = {};
for (const persistedKey of Object.keys(subtree)) {
if (subtreeConfig[persistedKey]) {
filteredSubtree[persistedKey] = subtree[persistedKey];
}
}
return filteredSubtree;
}
}
export default new PersistenceRegistry();
+33
View File
@@ -0,0 +1,33 @@
Jitsi Meet - redux state persistence
====================================
Jitsi Meet has a persistence layer that persists specific subtrees of the redux
store/state into window.localStorage (on Web) or AsyncStorage (on mobile).
Usage
=====
If a subtree of the redux store should be persisted (e.g.
`'features/base/profile'`), then persistence for that subtree should be
requested by registering the subtree with `PersistenceRegistry`.
For example, to register the field `profile` of the redux subtree
`'features/base/profile'` to be persisted, use:
```javascript
PersistenceRegistry.register('features/base/profile', {
profile: true
});
```
in the `reducer.js` of the `base/profile` feature.
When it's done, Jitsi Meet will automatically persist these subtrees and
rehydrate them on startup.
Throttling
==========
To avoid too frequent write operations in the storage, we utilize throttling in
the persistence layer, meaning that the storage gets persisted only once every 2
seconds, even if multiple redux state changes occur during this period. The
throttling timeout can be configured in
```
react/features/base/storage/middleware.js#PERSIST_STATE_DELAY
```
+1
View File
@@ -0,0 +1 @@
export * from './native';
+4
View File
@@ -0,0 +1,4 @@
export * from './_';
export { default as PersistenceRegistry } from './PersistenceRegistry';
import './middleware';
+39
View File
@@ -0,0 +1,39 @@
// @flow
import _ from 'lodash';
import { MiddlewareRegistry, toState } from '../redux';
import PersistenceRegistry from './PersistenceRegistry';
/**
* The delay in milliseconds that passes between the last state change and the
* persisting of that state in the storage.
*/
const PERSIST_STATE_DELAY = 2000;
/**
* A throttled function to avoid repetitive state persisting.
*/
const throttledPersistState
= _.throttle(
state => PersistenceRegistry.persistState(state),
PERSIST_STATE_DELAY);
/**
* A master MiddleWare to selectively persist state. Please use the
* {@link persisterconfig.json} to set which subtrees of the redux state should
* be persisted.
*
* @param {Store} store - The redux store.
* @returns {Function}
*/
MiddlewareRegistry.register(store => next => action => {
const oldState = toState(store);
const result = next(action);
const newState = toState(store);
oldState === newState || throttledPersistState(newState);
return result;
});
@@ -0,0 +1,167 @@
import { AsyncStorage } from 'react-native';
/**
* A Web Sorage API implementation used for polyfilling
* {@code window.localStorage} and/or {@code window.sessionStorage}.
* <p>
* The Web Storage API is synchronous whereas React Native's builtin generic
* storage API {@code AsyncStorage} is asynchronous so the implementation with
* persistence is optimistic: it will first store the value locally in memory so
* that results can be served synchronously and then persist the value
* asynchronously. If an asynchronous operation produces an error, it's ignored.
*/
export default class Storage {
/**
* Initializes a new {@code Storage} instance. Loads all previously
* persisted data items from React Native's {@code AsyncStorage} if
* necessary.
*
* @param {string|undefined} keyPrefix - The prefix of the
* {@code AsyncStorage} keys to be persisted by this storage.
*/
constructor(keyPrefix) {
/**
* The prefix of the {@code AsyncStorage} keys persisted by this
* storage. If {@code undefined}, then the data items stored in this
* storage will not be persisted.
*
* @private
* @type {string}
*/
this._keyPrefix = keyPrefix;
if (typeof this._keyPrefix !== 'undefined') {
// Load all previously persisted data items from React Native's
// AsyncStorage.
this._initialized = new Promise(resolve => {
AsyncStorage.getAllKeys().then((...getAllKeysCallbackArgs) => {
// XXX The keys argument of getAllKeys' callback may
// or may not be preceded by an error argument.
const keys
= getAllKeysCallbackArgs[
getAllKeysCallbackArgs.length - 1
].filter(key => key.startsWith(this._keyPrefix));
AsyncStorage.multiGet(keys)
.then((...multiGetCallbackArgs) => {
// XXX The result argument of multiGet may or may not be
// preceded by an errors argument.
const result
= multiGetCallbackArgs[
multiGetCallbackArgs.length - 1
];
const keyPrefixLength
= this._keyPrefix && this._keyPrefix.length;
// eslint-disable-next-line prefer-const
for (let [ key, value ] of result) {
key = key.substring(keyPrefixLength);
// XXX The loading of the previously persisted data
// items from AsyncStorage is asynchronous which
// means that it is technically possible to invoke
// setItem with a key before the key is loaded from
// AsyncStorage.
if (!this.hasOwnProperty(key)) {
this[key] = value;
}
}
resolve();
});
});
});
}
}
/**
* Removes all keys from this storage.
*
* @returns {void}
*/
clear() {
for (const key of Object.keys(this)) {
this.removeItem(key);
}
}
/**
* Returns the value associated with a specific key in this storage.
*
* @param {string} key - The name of the key to retrieve the value of.
* @returns {string|null} The value associated with {@code key} or
* {@code null}.
*/
getItem(key) {
return this.hasOwnProperty(key) ? this[key] : null;
}
/**
* Returns the value associated with a specific key in this storage in an
* async manner. This method is required for those cases where we need the
* stored data but we're not sure yet whether the {@code Storage} is already
* initialised or not - e.g. on app start.
*
* @param {string} key - The name of the key to retrieve the value of.
* @private
* @returns {Promise}
*/
_getItemAsync(key) {
return new Promise(
resolve =>
AsyncStorage.getItem(
`${String(this._keyPrefix)}${key}`,
(error, result) => resolve(result ? result : null)));
}
/**
* Returns the name of the nth key in this storage.
*
* @param {number} n - The zero-based integer index of the key to get the
* name of.
* @returns {string} The name of the nth key in this storage.
*/
key(n) {
const keys = Object.keys(this);
return n < keys.length ? keys[n] : null;
}
/**
* Returns an integer representing the number of data items stored in this
* storage.
*
* @returns {number}
*/
get length() {
return Object.keys(this).length;
}
/**
* Removes a specific key from this storage.
*
* @param {string} key - The name of the key to remove.
* @returns {void}
*/
removeItem(key) {
delete this[key];
typeof this._keyPrefix === 'undefined'
|| AsyncStorage.removeItem(`${String(this._keyPrefix)}${key}`);
}
/**
* Adds a specific key to this storage and associates it with a specific
* value. If the key exists already, updates its value.
*
* @param {string} key - The name of the key to add/update.
* @param {string} value - The value to associate with {@code key}.
* @returns {void}
*/
setItem(key, value) {
value = String(value); // eslint-disable-line no-param-reassign
this[key] = value;
typeof this._keyPrefix === 'undefined'
|| AsyncStorage.setItem(`${String(this._keyPrefix)}${key}`, value);
}
}
@@ -0,0 +1 @@
import './polyfills-browser';
@@ -0,0 +1,19 @@
import Storage from './Storage';
(global => {
// localStorage
if (typeof global.localStorage === 'undefined') {
global.localStorage = new Storage('@jitsi-meet/');
}
// sessionStorage
//
// Required by:
// - herment
// - Strophe
if (typeof global.sessionStorage === 'undefined') {
global.sessionStorage = new Storage();
}
})(global || window || this); // eslint-disable-line no-invalid-this