UNPKG

reduxed-chrome-storage

Version:

Redux interface to chrome.storage (browser.storage). A unified way to use Redux in all modern browser extensions. The only way to get Redux working in Manifest V3 Chrome extensions

533 lines (507 loc) 21 kB
/** * @license * ReduxedChromeStorage v3.0.10 * https://github.com/hindmost/reduxed-chrome-storage * Copyright (c) Savr Goryaev aka hindmost * * This source code is licensed under the MIT license * https://github.com/hindmost/reduxed-chrome-storage/blob/master/LICENSE * * Dependencies: * * uuid v8.3.2 */ // Unique ID creation requires a high quality random # generator. In the browser we therefore // require the crypto API and do not support built-in fallback to lower quality random number // generators (like Math.random()). var getRandomValues; var rnds8 = new Uint8Array(16); function rng() { // lazy load so that environments that need to polyfill have a chance to do so if (!getRandomValues) { // getRandomValues needs to be invoked in a context where "this" is a Crypto implementation. Also, // find the complete implementation of crypto (msCrypto) on IE11. getRandomValues = typeof crypto !== 'undefined' && crypto.getRandomValues && crypto.getRandomValues.bind(crypto) || typeof msCrypto !== 'undefined' && typeof msCrypto.getRandomValues === 'function' && msCrypto.getRandomValues.bind(msCrypto); if (!getRandomValues) { throw new Error('crypto.getRandomValues() not supported. See https://github.com/uuidjs/uuid#getrandomvalues-not-supported'); } } return getRandomValues(rnds8); } var REGEX = /^(?:[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}|00000000-0000-0000-0000-000000000000)$/i; function validate(uuid) { return typeof uuid === 'string' && REGEX.test(uuid); } /** * Convert array of 16 byte values to UUID string format of the form: * XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX */ var byteToHex = []; for (var i = 0; i < 256; ++i) { byteToHex.push((i + 0x100).toString(16).substr(1)); } function stringify(arr) { var offset = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 0; // Note: Be careful editing this code! It's been tuned for performance // and works in ways you may not expect. See https://github.com/uuidjs/uuid/pull/434 var uuid = (byteToHex[arr[offset + 0]] + byteToHex[arr[offset + 1]] + byteToHex[arr[offset + 2]] + byteToHex[arr[offset + 3]] + '-' + byteToHex[arr[offset + 4]] + byteToHex[arr[offset + 5]] + '-' + byteToHex[arr[offset + 6]] + byteToHex[arr[offset + 7]] + '-' + byteToHex[arr[offset + 8]] + byteToHex[arr[offset + 9]] + '-' + byteToHex[arr[offset + 10]] + byteToHex[arr[offset + 11]] + byteToHex[arr[offset + 12]] + byteToHex[arr[offset + 13]] + byteToHex[arr[offset + 14]] + byteToHex[arr[offset + 15]]).toLowerCase(); // Consistency check for valid UUID. If this throws, it's likely due to one // of the following: // - One or more input array values don't map to a hex octet (leading to // "undefined" in the uuid) // - Invalid input values for the RFC `version` or `variant` fields if (!validate(uuid)) { throw TypeError('Stringified UUID is invalid'); } return uuid; } function v4(options, buf, offset) { options = options || {}; var rnds = options.random || (options.rng || rng)(); // Per 4.4, set bits for version and `clock_seq_hi_and_reserved` rnds[6] = rnds[6] & 0x0f | 0x40; rnds[8] = rnds[8] & 0x3f | 0x80; // Copy bytes to buffer, if provided if (buf) { offset = offset || 0; for (var i = 0; i < 16; ++i) { buf[offset + i] = rnds[i]; } return buf; } return stringify(rnds); } /** * Utility function: returns a deep copy of its argument */ function cloneDeep(o) { return o == null || typeof o !== 'object' ? o : JSON.parse(JSON.stringify(o)); } /** * Utility function: checks deeply if its two arguments are equal */ function isEqual(a, b) { if (a === b) return true; if (a == null || typeof a !== 'object' || b == null || typeof b !== 'object' || Array.isArray(a) !== Array.isArray(b)) return false; const keysA = Object.keys(a), keysB = Object.keys(b); if (keysA.length !== keysB.length) return false; for (const key of keysA) { if (keysB.indexOf(key) <= -1 || !isEqual(a[key], b[key])) return false; } return true; } /** * Utility function: returns the deep difference between its two arguments */ function diffDeep(a, b) { if (a === b) return undefined; if (a == null || typeof a !== 'object' || b == null || typeof b !== 'object') return a; if (Array.isArray(a) || Array.isArray(b)) return isEqual(a, b) ? undefined : a; const keysA = Object.keys(a), keysB = Object.keys(b); let eq = true; const ret = keysA.reduce((acc, key) => { const diff = keysB.indexOf(key) > -1 ? diffDeep(a[key], b[key]) : a[key]; if (typeof diff === 'undefined') return acc; eq = false; acc[key] = diff; return acc; }, {}); keysB.forEach(key => { if (keysA.indexOf(key) > -1) return; eq = false; ret[key] = undefined; }); return eq ? undefined : ret; } function mergeOrReplace(a, b, withReduction) { if (Array.isArray(b)) return cloneDeep(b); if (a == null || typeof a !== 'object' || Array.isArray(a) || b == null || typeof b !== 'object') return cloneDeep(typeof b !== 'undefined' ? b : a); const ret = Object.keys(a).concat(Object.keys(b).filter(key => !(key in a))).reduce((acc, key) => { return acc[key] = mergeOrReplace(a[key], b[key], withReduction), acc; }, {}); if (!withReduction) return ret; const keysB = Object.keys(b); Object.keys(a).forEach(key => { keysB.indexOf(key) > -1 || delete ret[key]; }); return ret; } const packState = (state, id, ts) => [id, ts, state]; const unpackState = (data) => { if (typeof data === 'undefined' || !Array.isArray(data) || data.length !== 3) return [data, '', 0]; const [id, ts, state] = data; return typeof id === 'string' && typeof ts === 'number' ? [state, id, ts] : [data, '', 0]; }; class ReduxedStorage { constructor(container, storage, isolated, plainActions, outdatedTimeout, localChangeListener, resetState) { this.container = container; this.storage = storage; this.isolated = isolated; this.plain = plainActions; this.timeout = outdatedTimeout ? Math.max(outdatedTimeout, 500) : 1000; this.resetState = resetState; this.store = this._instantiateStore(); this.state = null; this.id = v4(); this.tmstamp = 0; this.outdted = []; if (typeof localChangeListener === 'function') this.lisner = localChangeListener; this.lisners = []; this.getState = this.getState.bind(this); this.subscribe = this.subscribe.bind(this); this.dispatch = this.dispatch.bind(this); this.replaceReducer = this.replaceReducer.bind(this); this[Symbol.observable] = this[Symbol.observable].bind(this); } init() { this.tmstamp || this.isolated || this.storage.subscribe((data, oldData) => { const [state, id, timestamp] = unpackState(data); if (id === this.id || isEqual(state, this.state)) return; const newTime = timestamp >= this.tmstamp; const newState = newTime ? mergeOrReplace(this.state, state, true) : mergeOrReplace(state, this.state, true); if (!newTime && isEqual(newState, this.state)) return; this._setState(newState, timestamp); this._renewStore(); if (!isEqual(newState, state)) { this._send2Storage(); } this._callListeners(); }); const defaultState = this.store.getState(); // return a promise to be resolved when the last state (if any) // is restored from chrome.storage return new Promise(resolve => { this.storage.load(data => { const [storedState, , timestamp] = unpackState(data); let newState = storedState ? mergeOrReplace(defaultState, storedState) : defaultState; if (this.resetState) { newState = mergeOrReplace(newState, this.resetState); } this._setState(newState, timestamp); this._renewStore(); isEqual(newState, storedState) || this._send2Storage(); resolve(this); }); }); } initFrom(state) { this._setState(state, 0); this._renewStore(); return this; } _setState(data, timestamp) { this.state = cloneDeep(data); timestamp = typeof timestamp !== 'undefined' ? timestamp : Date.now(); if (timestamp > this.tmstamp) { this.tmstamp = timestamp; } } _renewStore() { this.plain ? this.unsub && this.unsub() : this._clean(); const store = this.store = this._instantiateStore(this.state); const now = Date.now(); const n = this.outdted.length; this.outdted = this.outdted.map(([t, u], i) => t || i >= n - 1 ? [t, u] : [now, u]); let state0 = cloneDeep(this.state); const unsubscribe = this.store.subscribe(() => { const state = store && store.getState(); const sameStore = this.store === store; this._clean(); if (isEqual(state, this.state)) return; if (sameStore) { this._setState(state); } else { const diff = diffDeep(state, state0); if (typeof diff === 'undefined') return; this._setState(mergeOrReplace(this.state, diff)); this._renewStore(); } this._send2Storage(); this._callListeners(true, state0); state0 = cloneDeep(state); }); if (this.plain) this.unsub = unsubscribe; else this.outdted.push([0, unsubscribe]); } _clean() { if (this.plain) return; const now = Date.now(); const n = this.outdted.length; this.outdted.forEach(([timestamp, unsubscribe], i) => { if (i >= n - 1 || now - timestamp < this.timeout) return; unsubscribe(); delete this.outdted[i]; }); } _instantiateStore(state) { const store = this.container(state); if (typeof store !== 'object' || typeof store.getState !== 'function') throw new Error(`Invalid 'storeCreatorContainer' supplied`); return store; } _send2Storage() { this.storage.save(packState(this.state, this.id, this.tmstamp)); } _callListeners(local, oldState) { local && this.lisner && this.lisner(this, oldState); for (const fn of this.lisners) { fn(); } } getState() { return this.state; } subscribe(fn) { typeof fn === 'function' && this.lisners.push(fn); return () => { if (typeof fn === 'function') { this.lisners = this.lisners.filter(v => v !== fn); } }; } dispatch(action) { return this.store.dispatch(action); } replaceReducer(nextReducer) { if (typeof nextReducer === 'function') { this.store.replaceReducer(nextReducer); } return this; } [Symbol.observable]() { const getState = this.getState; const subscribe = this.subscribe; return { subscribe(observer) { if (typeof observer !== 'object' || observer === null) { throw new TypeError('Expected the observer to be an object.'); } function observeState() { const observerAsObserver = observer; observerAsObserver.next && observerAsObserver.next(getState()); } observeState(); const unsubscribe = subscribe(observeState); return { unsubscribe }; }, [Symbol.observable]() { return this; } }; } } var StorageAreaName; (function (StorageAreaName) { StorageAreaName["local"] = "local"; StorageAreaName["sync"] = "sync"; })(StorageAreaName || (StorageAreaName = {})); const usageSize = (data) => new TextEncoder().encode(Object.entries(data).map(([key, val]) => key + JSON.stringify(val)).join('')).length; class WrappedStorage { constructor({ namespace, area, key }) { this.ns = namespace; this.areaName = area === StorageAreaName.sync ? StorageAreaName.sync : StorageAreaName.local; this.key = key || 'reduxed'; this.listeners = []; this.errListeners = []; } regShared() { this.regListener((newValue, oldValue) => { for (const listener of this.listeners) { listener(newValue, oldValue); } }); } regListener(listener) { this.ns.storage.onChanged.addListener((changes, area) => { if (area !== this.areaName || !(this.key in changes)) return; const { newValue, oldValue } = changes[this.key]; newValue && listener(newValue, oldValue); }); } subscribe(listener) { typeof listener === 'function' && this.listeners.push(listener); } subscribeForError(listener) { typeof listener === 'function' && this.errListeners.push(listener); } fireErrorListeners(message, exceeded) { for (const listener of this.errListeners) { listener(message, exceeded); } } callbackOnLoad(data, callback, all) { callback(!this.ns.runtime.lastError && (all ? data : data && data[this.key])); } callbackOnSave(data, area) { if (!this.ns.runtime.lastError) return; const { message } = this.ns.runtime.lastError; if (!message || !data || !area) { this.fireErrorListeners(message || '', false); return; } const b = this.areaName === StorageAreaName.sync && area.QUOTA_BYTES_PER_ITEM && usageSize({ [this.key]: data }) > area.QUOTA_BYTES_PER_ITEM; if (b) { this.fireErrorListeners(message, true); return; } this.load((allData) => { const b = typeof allData === 'object' && area.QUOTA_BYTES > 0 && usageSize(Object.assign(Object.assign({}, allData), { [this.key]: data })) > area.QUOTA_BYTES; this.fireErrorListeners(message, b); }, true); } } class WrappedChromeStorage extends WrappedStorage { constructor({ namespace, area, key }) { super({ namespace, area, key }); this.areaApi = this.ns.storage[this.areaName]; } load(fn, all) { typeof fn === 'function' && this.areaApi.get(all ? null : this.key, data => { this.callbackOnLoad(data, fn, all); }); } save(data) { this.areaApi.set({ [this.key]: data }, () => { this.callbackOnSave(data, this.areaApi); }); } } class WrappedBrowserStorage extends WrappedStorage { constructor({ namespace, area, key }) { super({ namespace, area, key }); this.areaApi = this.ns.storage[this.areaName]; } load(fn, all) { typeof fn === 'function' && this.areaApi.get(all ? null : this.key).then(data => { this.callbackOnLoad(data, fn); }); } save(data) { this.areaApi.set({ [this.key]: data }).then(() => { this.callbackOnSave(data, this.areaApi); }); } } var Namespace; (function (Namespace) { Namespace["chrome"] = "chrome"; Namespace["browser"] = "browser"; })(Namespace || (Namespace = {})); /** * Sets up Reduxed Chrome Storage * @param storeCreatorContainer a function that calls a store creator * and returns the created Redux store. * Receives one argument to be passed as the preloadedState argument * into the store creator. Store creator is either the Redux's createStore() * or any function that wraps the createStore(), e.g. RTK's configureStore() * @param options * @param options.namespace string to identify the APIs namespace to be used, * either 'chrome' or 'browser'. If this and the next two options are missing, * the chrome namespace is used by default * @param options.chromeNs the chrome namespace within Manifest V2 extension. * If this option is supplied, the previous one is ignored * @param options.browserNs the browser namespace within Firefox extension, * or the chrome namespace within Manifest V3 chrome extension. * If this option is supplied, the previous two are ignored * @param options.storageArea the name of chrome.storage area to be used, * either 'local' or 'sync'. Defaults to 'local' * @param options.storageKey the key under which the state will be * stored/tracked in chrome.storage. Defaults to 'reduxed' * @param options.isolated check this option if your store in this specific * extension component isn't supposed to receive state changes from other * extension components. Defaults to false * @param options.plainActions check this option if your store is only supposed * to dispatch plain object actions. Defaults to false * @param options.outdatedTimeout max. time (in ms) to wait for outdated (async) * actions to be completed. Defaults to 1000. This option is ignored * if at least one of the previous two is checked * @param listeners * @param listeners.onGlobalChange a function to be called whenever the state * changes that may be caused by any extension component (popup etc.). * Receives two arguments: * 1) a temporary store representing the current state; * 2) the previous state * @param listeners.onLocalChange a function to be called whenever a store in * this specific extension component causes a change in the state. * Receives two arguments: * 1) reference to the store that caused this change in the state; * 2) the previous state * @param listeners.onError a function to be called whenever an error * occurs during chrome.storage update. Receives two arguments: * 1) an error message defined by storage API; * 2) a boolean indicating if the limit for the used storage area is exceeded * @returns a function that creates asynchronously a Redux store replacement * connected to the state stored in chrome.storage. * Receives one optional argument: some value to which the state * will be reset entirely or partially upon the store replacement creation. * Returns a Promise to be resolved when the created store replacement is ready */ function setupReduxed(storeCreatorContainer, options, listeners) { const { namespace, chromeNs, browserNs, storageArea, storageKey, isolated, plainActions, outdatedTimeout } = options || {}; const { onGlobalChange, onLocalChange, onError } = listeners || {}; if (typeof storeCreatorContainer !== 'function') throw new Error(`Missing argument for 'storeCreatorContainer'`); const storage = browserNs || namespace === Namespace.browser ? new WrappedBrowserStorage({ namespace: browserNs || browser, area: storageArea, key: storageKey }) : new WrappedChromeStorage({ namespace: chromeNs || chrome, area: storageArea, key: storageKey }); typeof onGlobalChange === 'function' && storage.regListener((data, oldData) => { const store = new ReduxedStorage(storeCreatorContainer, storage, true, plainActions); const [state] = unpackState(data); const [oldState] = unpackState(oldData); onGlobalChange(store.initFrom(state), oldState); }); isolated || storage.regShared(); const instantiate = (resetState) => { onError && storage.subscribeForError(onError); const store = new ReduxedStorage(storeCreatorContainer, storage, isolated, plainActions, outdatedTimeout, onLocalChange, resetState); return store.init(); }; return instantiate; } export { cloneDeep, diffDeep, isEqual, mergeOrReplace, setupReduxed };