UNPKG

@oqton/redux-black-box

Version:

Declare side effects as black boxes in redux: an alternative for redux-thunk, redux-saga, redux-loop, ...

250 lines (223 loc) 8.63 kB
class AbstractBlackBox { constructor() { this._name = this.constructor.name; this._loadStarted = false; this._loaded = false; this._unloaded = false; this._abortController = new AbortController(); } guardedStore({ dispatch, getState }) { if (!this._guardedStore) { this._guardedStore = { dispatch: (action) => { if (this._unloaded) { throw new Error(`This black box (${this._name}) has been removed from the redux state: ` + `it can no longer dispatch an action (${action.type})`); // basic cancellation } return dispatch(action); }, getState, abortSignal: this._abortController.signal }; } return this._guardedStore; } onLoadInternal(store) { console.assert(!this._loadStarted, 'black box already loaded'); this._loadStarted = true; this.onLoad(this.guardedStore(store)); this._loaded = true; } onUnloadInternal(store) { console.assert(this._loaded, 'black box not yet loaded'); console.assert(!this._unloaded, 'black box already unloaded'); this._unloaded = true; this._abortController.abort(); this.onUnload(this.guardedStore(store)); } onActionInternal(action, store) { console.assert(this._loaded, 'black box not yet loaded'); console.assert(!this._unloaded, 'black box already unloaded'); this.onAction(action, this.guardedStore(store)); } onLoad({ dispatch, getState }) { throw new Error('Not implemented'); } onUnload({ getState }) { throw new Error('Not implemented'); } onAction(action, { dispatch, getState }) {} } class PromiseBlackBox extends AbstractBlackBox { constructor(promiseGenerator) { super(); this._resolved = false; this._promiseGenerator = promiseGenerator; } async onLoad({ dispatch, abortSignal }) { try { // call the promise generator: note that this function can return a non-standard promise with `.cancel()` method this._promise = this._promiseGenerator(abortSignal); // wait for it to finish const action = await this._promise; this._resolved = true; try { // if the black box has unloaded, don't do anything if (action && !this._unloaded) return dispatch(action); } catch (e) { console.error(`An error was thrown while dispatching an action: ${e}`); // console.error(e); throw e; } } catch (e) { this._resolved = true; console.error(`An error was thrown during execution of this black box (${this._name}): ${e}`); // console.error(e); throw e; } } onUnload() { if (!this._resolved && this._promise.cancel) this._promise.cancel(); } } class ReduxBlackBox extends AbstractBlackBox { constructor(actionStart, actionAfter = null, take = () => true) { super(); console.assert(actionStart, 'A start action is required'); this._finished = !actionAfter; // if no action after, no need to wait for anything this._actionStart = actionStart; this._actionAfter = actionAfter; this._take = take; } async onLoad({ dispatch }) { await null; // force async dispatch if (!this._unloaded) dispatch(this._actionStart); } onUnload() { } async onAction(action, { dispatch, getState }) { if (this._finished || this._unloaded || !this._loaded) return; if (this._take(action, getState())) { this._finished = true; await null;// force async dispatch if (this._actionAfter instanceof Function) { dispatch(this._actionAfter(action)); } else { dispatch(this._actionAfter); } } } } const patternToFilter = (pattern) => { if (pattern === undefined || pattern === '*') return () => true; // wildcard matches all actions if (typeof pattern === 'function') return pattern; // pattern can be a function if (typeof pattern === 'string') return act => act.type === pattern; // string matches action.type // If pattern is an array, each item in the array is matched with aforementioned rules if (Array.isArray(pattern)) { const filterArray = pattern.map(patternToFilter); return act => filterArray.some(filter => filter(act)); } throw new Error('take only accepts a function, wildcard, string or array as pattern'); }; class AsyncBlackBox extends AbstractBlackBox { constructor(promiseGenerator) { super(); this._name = promiseGenerator.name; this._resolved = false; this._promiseGenerator = promiseGenerator; this._takeFilters = []; this._take = this._take.bind(this); } async _take(pattern) { return new Promise(resolve => this._takeFilters.push({ filter: patternToFilter(pattern), resolve })); } async onLoad(store) { await null; // force delay of execution of promise generator; mainly to make sure dispatch is async if (this._unloaded) return; try { this._promise = this._promiseGenerator({ ...store, take: this._take }); if (this._unloaded && this._promise.cancel) this._promise.cancel(); await this._promise; this._resolved = true; } catch (e) { this._resolved = true; console.error(`An error was thrown during execution of this black box (${this._name}): ${e}`); // console.error(e); throw e; } } onAction(action) { // matching takefilters const matchedFilters = this._takeFilters.filter(({ filter }) => filter(action)); // remaining takefilters this._takeFilters = this._takeFilters.filter(takeFilter => !matchedFilters.includes(takeFilter)); // resolve matching promises matchedFilters.forEach(({ resolve }) => resolve(action)); } onUnload() { if (!this._resolved && this._promise && this._promise.cancel) this._promise.cancel(); } } function findBlackBoxesInObj(obj, ignoredPaths, subsystems) { if (obj instanceof AbstractBlackBox) { subsystems.push(obj); } else if (Array.isArray(obj)) { // eslint-disable-next-line no-use-before-define obj.forEach((child, key) => recurseFindBlackBoxesInObj(key, child, ignoredPaths, subsystems)); } else if ((typeof obj === 'object') && (obj !== null)) { // eslint-disable-next-line no-use-before-define Object.keys(obj).forEach(key => recurseFindBlackBoxesInObj(key, obj[key], ignoredPaths, subsystems)); } return subsystems; } function recurseFindBlackBoxesInObj(key, child, ignoredPaths, subsystems) { let newIgnoredPaths; if (ignoredPaths.length === 0) { newIgnoredPaths = ignoredPaths; } else { const isIgnoredPath = ignoredPaths.some(ignoredPath => ignoredPath.length === 1 && (ignoredPath[0] === key || ignoredPath[0] === '*')); if (isIgnoredPath) return; newIgnoredPaths = ignoredPaths .map(ignoredPath => (ignoredPath.length > 1 && (ignoredPath[0] === key || ignoredPath[0] === '*') ? ignoredPath.slice(1) : null)) .filter(ignoredPath => ignoredPath !== null); } findBlackBoxesInObj(child, newIgnoredPaths, subsystems); } function createBlackBoxMiddleware(ignoredPaths) { const ignoredPathArrays = ignoredPaths.map(p => (Array.isArray(p) ? p : p.split('.'))); return function blackBoxMiddleware({ dispatch, getState }) { let lock = false; let blackBoxesBefore = []; return next => (action) => { const returnValue = next(action); const blackBoxesAfter = findBlackBoxesInObj(getState(), ignoredPathArrays, []); const addedBlackBoxes = blackBoxesAfter.filter(blackBox => !blackBoxesBefore.includes(blackBox)); const removedBlackBoxes = blackBoxesBefore.filter(blackBox => !blackBoxesAfter.includes(blackBox)); blackBoxesBefore = blackBoxesAfter; try { if (lock) throw new Error('Black boxes may not synchronously dispatch actions.'); lock = true; addedBlackBoxes.forEach(blackBox => blackBox.onLoadInternal({ dispatch, getState })); removedBlackBoxes.forEach(blackBox => blackBox.onUnloadInternal({ getState })); blackBoxesAfter.forEach(blackBox => blackBox.onActionInternal(action, { dispatch, getState })); } catch (e) { console.error(`Error occurred while processing action: ${JSON.stringify(action)}`); console.error(e); throw new Error(`Error occurred while processing action: ${JSON.stringify(action)}`); } finally { lock = false; } return returnValue; }; }; } const blackBoxMiddleware = createBlackBoxMiddleware([]); module.exports = { AbstractBlackBox, PromiseBlackBox, ReduxBlackBox, AsyncBlackBox, blackBoxMiddleware, createBlackBoxMiddleware };