import { stringify, parse } from 'jsan'; import socketClusterClient, { AGClientSocket } from 'socketcluster-client'; import configureStore from './configureStore'; import { defaultSocketOptions } from './constants'; import getHostForRN from 'rn-host-detect'; import { Action, ActionCreator, Reducer, StoreEnhancer, StoreEnhancerStoreCreator, } from 'redux'; import { EnhancedStore, LiftedAction, LiftedState, PerformAction, } from '@redux-devtools/instrument'; import { ActionCreatorObject, ErrorAction, evalAction, catchErrors, getActionsArray, getLocalFilter, isFiltered, filterStagedActions, filterState, LocalFilter, State, } from '@redux-devtools/utils'; function async(fn: () => unknown) { setTimeout(fn, 0); } function str2array( str: string | readonly string[] | undefined, ): readonly string[] | undefined { return typeof str === 'string' ? [str] : str && str.length > 0 ? str : undefined; } function getRandomId() { return Math.random().toString(36).substr(2); } interface AutoReconnectOptions { readonly randomness?: number; } interface SocketOptions { readonly secure?: boolean; readonly hostname: string; readonly port: number; readonly autoReconnect?: boolean; readonly autoReconnectOptions?: AutoReconnectOptions; } interface Filters { /** * @deprecated Use actionsDenylist instead. */ readonly blacklist?: string | readonly string[]; /** * @deprecated Use actionsAllowlist instead. */ readonly whitelist?: string | readonly string[]; readonly denylist?: string | readonly string[]; readonly allowlist?: string | readonly string[]; } interface Options> { readonly hostname?: string; readonly realtime?: boolean; readonly maxAge?: number; readonly trace?: boolean | ((action: A) => string | undefined); readonly traceLimit?: number; readonly shouldHotReload?: boolean; readonly shouldRecordChanges?: boolean; readonly shouldStartLocked?: boolean; readonly pauseActionType?: unknown; readonly name?: string; readonly filters?: Filters; /** * @deprecated Use actionsDenylist instead. */ readonly actionsBlacklist?: string | readonly string[]; /** * @deprecated Use actionsAllowlist instead. */ readonly actionsWhitelist?: string | readonly string[]; readonly actionsDenylist?: string | readonly string[]; readonly actionsAllowlist?: string | readonly string[]; readonly port?: number; readonly secure?: boolean; readonly suppressConnectErrors?: boolean; readonly startOn?: string | readonly string[]; readonly stopOn?: string | readonly string[]; readonly sendOn?: string | readonly string[]; readonly sendOnError?: number; readonly sendTo?: string; readonly id?: string; readonly actionCreators?: { [key: string]: ActionCreator>; }; readonly stateSanitizer?: ((state: S, index?: number) => S) | undefined; readonly actionSanitizer?: | (>(action: A, id?: number) => A) | undefined; } interface MessageToRelay { type: 'STATE' | 'ACTION' | 'START' | 'STOP' | 'ERROR'; id: string; name: string | undefined; instanceId: string; payload?: string; action?: string | ActionCreatorObject[]; isExcess?: boolean | undefined; nextActionId?: number | undefined; } interface ImportMessage { readonly type: 'IMPORT'; readonly state: string; } interface SyncMessage { readonly type: 'SYNC'; readonly state: string; readonly id: string | undefined; readonly instanceId: string | number; } interface UpdateMessage { readonly type: 'UPDATE'; } interface StartMessage { readonly type: 'START'; } interface StopMessage { readonly type: 'STOP'; } interface DisconnectedMessage { readonly type: 'DISCONNECTED'; } interface ActionMessage { readonly type: 'ACTION'; readonly action: string | { args: string[]; rest: string; selected: number }; } interface DispatchMessage> { readonly type: 'DISPATCH'; // eslint-disable-next-line @typescript-eslint/no-empty-object-type readonly action: LiftedAction; } type Message> = | ImportMessage | SyncMessage | UpdateMessage | StartMessage | StopMessage | DisconnectedMessage | ActionMessage | DispatchMessage; class DevToolsEnhancer, PreloadedState> { // eslint-disable-next-line @typescript-eslint/no-empty-object-type store!: EnhancedStore; filters: LocalFilter | undefined; instanceId?: string; socket?: AGClientSocket; sendTo?: string; instanceName: string | undefined; appInstanceId!: string; stateSanitizer: ((state: S, index?: number) => S) | undefined; actionSanitizer: ((action: A, id?: number) => A) | undefined; isExcess?: boolean; actionCreators?: (() => ActionCreatorObject[]) | ActionCreatorObject[]; isMonitored?: boolean; lastErrorMsg?: string | Event; started?: boolean; socketOptions!: SocketOptions; suppressConnectErrors!: boolean; startOn: readonly string[] | undefined; stopOn: readonly string[] | undefined; sendOn: readonly string[] | undefined; sendOnError: number | undefined; channel?: string; errorCounts: { [errorName: string]: number } = {}; lastAction?: unknown; paused?: boolean; locked?: boolean; getLiftedStateRaw() { return this.store.liftedStore.getState(); } getLiftedState() { return filterStagedActions(this.getLiftedStateRaw(), this.filters); } send = () => { if (!this.instanceId) this.instanceId = (this.socket && this.socket.id) || getRandomId(); try { fetch(this.sendTo!, { method: 'POST', headers: { 'content-type': 'application/json', }, body: JSON.stringify({ type: 'STATE', id: this.instanceId, name: this.instanceName, payload: stringify(this.getLiftedState()), }), }).catch(function (err) { console.log(err); }); } catch (err) { console.log(err); } }; relay( type: 'STATE' | 'ACTION' | 'START' | 'STOP' | 'ERROR', state?: State | S | string, action?: PerformAction | ActionCreatorObject[], nextActionId?: number, ) { const message: MessageToRelay = { type, // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion id: this.socket!.id!, name: this.instanceName, instanceId: this.appInstanceId, }; if (state) { message.payload = type === 'ERROR' ? (state as string) : stringify( filterState( state as State, type, this.filters, this.stateSanitizer as ( state: unknown, index?: number, ) => unknown, this.actionSanitizer as | ((action: Action, id: number) => Action) | undefined, nextActionId!, ), ); } if (type === 'ACTION') { message.action = stringify( !this.actionSanitizer ? action : this.actionSanitizer( (action as PerformAction).action, nextActionId! - 1, ), ); message.isExcess = this.isExcess; message.nextActionId = nextActionId; } else if (action) { message.action = action as ActionCreatorObject[]; } // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion void this.socket!.transmit(this.socket!.id ? 'log' : 'log-noid', message); } dispatchRemotely( action: string | { args: string[]; rest: string; selected: number }, ) { try { const result = evalAction( action, this.actionCreators as ActionCreatorObject[], ); this.store.dispatch(result); } catch (e: unknown) { this.relay('ERROR', (e as Error).message); } } handleMessages = (message: Message) => { if ( message.type === 'IMPORT' || (message.type === 'SYNC' && // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion this.socket!.id && // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion message.id !== this.socket!.id) ) { this.store.liftedStore.dispatch({ type: 'IMPORT_STATE', // eslint-disable-next-line @typescript-eslint/no-empty-object-type nextLiftedState: parse(message.state) as LiftedState, }); } else if (message.type === 'UPDATE') { this.relay('STATE', this.getLiftedState()); } else if (message.type === 'START') { this.isMonitored = true; if (typeof this.actionCreators === 'function') this.actionCreators = this.actionCreators(); this.relay('STATE', this.getLiftedState(), this.actionCreators); } else if (message.type === 'STOP' || message.type === 'DISCONNECTED') { this.isMonitored = false; this.relay('STOP'); } else if (message.type === 'ACTION') { this.dispatchRemotely(message.action); } else if (message.type === 'DISPATCH') { this.store.liftedStore.dispatch(message.action); } }; sendError = (errorAction: ErrorAction) => { // Prevent flooding if (errorAction.message && errorAction.message === this.lastErrorMsg) return; this.lastErrorMsg = errorAction.message; async(() => { this.store.dispatch(errorAction as A); if (!this.started) this.send(); }); }; init(options: Options) { this.instanceName = options.name; this.appInstanceId = getRandomId(); const { blacklist, whitelist, denylist, allowlist } = options.filters || {}; this.filters = getLocalFilter({ actionsDenylist: denylist ?? options.actionsDenylist ?? blacklist ?? options.actionsBlacklist, actionsAllowlist: allowlist ?? options.actionsAllowlist ?? whitelist ?? options.actionsWhitelist, }); if (options.port) { this.socketOptions = { port: options.port, hostname: options.hostname || 'localhost', secure: options.secure, }; } else this.socketOptions = defaultSocketOptions; this.suppressConnectErrors = options.suppressConnectErrors !== undefined ? options.suppressConnectErrors : true; this.startOn = str2array(options.startOn); this.stopOn = str2array(options.stopOn); this.sendOn = str2array(options.sendOn); this.sendOnError = options.sendOnError; if (this.sendOn || this.sendOnError) { this.sendTo = options.sendTo || `${this.socketOptions.secure ? 'https' : 'http'}://${ this.socketOptions.hostname }:${this.socketOptions.port}`; this.instanceId = options.id; } if (this.sendOnError === 1) catchErrors(this.sendError); if (options.actionCreators) this.actionCreators = () => getActionsArray(options.actionCreators!); this.stateSanitizer = options.stateSanitizer; this.actionSanitizer = options.actionSanitizer; } login() { void (async () => { try { // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion const channelName = (await this.socket!.invoke( 'login', 'master', )) as string; this.channel = channelName; // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion for await (const data of this.socket!.subscribe(channelName)) { this.handleMessages(data as Message); } } catch (error) { console.log(error); } })(); this.started = true; this.relay('START'); } stop = (keepConnected?: boolean) => { this.started = false; this.isMonitored = false; if (!this.socket) return; void this.socket.unsubscribe(this.channel!); this.socket.closeChannel(this.channel!); if (!keepConnected) { this.socket.disconnect(); } }; start = () => { if ( this.started || (this.socket && this.socket.getState() === this.socket.CONNECTING) ) return; this.socket = socketClusterClient.create(this.socketOptions); void (async () => { // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion for await (const data of this.socket!.listener('error')) { // if we've already had this error before, increment it's counter, otherwise assign it '1' since we've had the error once. // eslint-disable-next-line no-prototype-builtins,@typescript-eslint/no-unsafe-argument this.errorCounts[data.error.name] = this.errorCounts.hasOwnProperty( data.error.name, ) ? this.errorCounts[data.error.name] + 1 : 1; if (this.suppressConnectErrors) { if (this.errorCounts[data.error.name] === 1) { console.log( 'remote-redux-devtools: Socket connection errors are being suppressed. ' + '\n' + "This can be disabled by setting suppressConnectErrors to 'false'.", ); console.log(data.error); } } else { console.log(data.error); } } })(); void (async () => { // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion for await (const data of this.socket!.listener('connect')) { console.log('connected to remotedev-server'); this.errorCounts = {}; // clear the errorCounts object, so that we'll log any new errors in the event of a disconnect this.login(); } })(); void (async () => { // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion for await (const data of this.socket!.listener('disconnect')) { this.stop(true); } })(); }; checkForReducerErrors = (liftedState = this.getLiftedStateRaw()) => { if (liftedState.computedStates[liftedState.currentStateIndex].error) { if (this.started) this.relay('STATE', filterStagedActions(liftedState, this.filters)); else this.send(); return true; } return false; }; // eslint-disable-next-line @typescript-eslint/no-empty-object-type monitorReducer = (state = {}, action: LiftedAction) => { this.lastAction = action.type; if (!this.started && this.sendOnError === 2 && this.store.liftedStore) async(this.checkForReducerErrors); else if ((action as PerformAction).action) { if ( this.startOn && !this.started && this.startOn.includes((action as PerformAction).action.type) ) async(this.start); else if ( this.stopOn && this.started && this.stopOn.includes((action as PerformAction).action.type) ) async(this.stop); else if ( this.sendOn && !this.started && this.sendOn.includes((action as PerformAction).action.type) ) async(this.send); } return state; }; // eslint-disable-next-line @typescript-eslint/no-empty-object-type handleChange(state: S, liftedState: LiftedState, maxAge: number) { if (this.checkForReducerErrors(liftedState)) return; if (this.lastAction === 'PERFORM_ACTION') { const nextActionId = liftedState.nextActionId; const liftedAction = liftedState.actionsById[nextActionId - 1]; if (isFiltered(liftedAction.action, this.filters)) return; this.relay('ACTION', state, liftedAction, nextActionId); if (!this.isExcess && maxAge) this.isExcess = liftedState.stagedActionIds.length >= maxAge; } else { if (this.lastAction === 'JUMP_TO_STATE') return; if (this.lastAction === 'PAUSE_RECORDING') { this.paused = liftedState.isPaused; } else if (this.lastAction === 'LOCK_CHANGES') { this.locked = liftedState.isLocked; } if (this.paused || this.locked) { if (this.lastAction) this.lastAction = undefined; else return; } this.relay('STATE', filterStagedActions(liftedState, this.filters)); } } enhance = (options: Options = {}): StoreEnhancer => { this.init({ ...options, hostname: getHostForRN(options.hostname || 'localhost'), }); const realtime = typeof options.realtime === 'undefined' ? process.env.NODE_ENV === 'development' : options.realtime; if (!realtime && !(this.startOn || this.sendOn || this.sendOnError)) return (f) => f; const maxAge = options.maxAge || 30; return ((next: StoreEnhancerStoreCreator) => { return ( reducer: Reducer, initialState?: PreloadedState | undefined, ) => { this.store = configureStore(next, this.monitorReducer, { maxAge, trace: options.trace, traceLimit: options.traceLimit, shouldCatchErrors: !!this.sendOnError, shouldHotReload: options.shouldHotReload, shouldRecordChanges: options.shouldRecordChanges, shouldStartLocked: options.shouldStartLocked, pauseActionType: options.pauseActionType || '@@PAUSED', })(reducer, initialState); if (realtime) this.start(); this.store.subscribe(() => { if (this.isMonitored) this.handleChange( this.store.getState(), this.getLiftedStateRaw(), maxAge, ); }); return this.store; }; }) as any; }; } export default , PreloadedState>( options?: Options, ) => new DevToolsEnhancer().enhance(options); const compose = (options: Options>) => (...funcs: StoreEnhancer[]) => (...args: unknown[]) => { const devToolsEnhancer = new DevToolsEnhancer(); function preEnhancer(createStore: StoreEnhancerStoreCreator) { return , PreloadedState>( reducer: Reducer, preloadedState?: PreloadedState | undefined, ) => { devToolsEnhancer.store = createStore(reducer, preloadedState) as any; return { ...devToolsEnhancer.store, dispatch: (action: Action) => devToolsEnhancer.locked ? action : devToolsEnhancer.store.dispatch(action), }; }; } return [preEnhancer, ...funcs].reduceRight( (composed, f) => f(composed) as any, devToolsEnhancer.enhance(options)( ...(args as [StoreEnhancerStoreCreator]), ), ); }; export function composeWithDevTools( ...funcs: [Options>] | StoreEnhancer[] ) { if (funcs.length === 0) { return new DevToolsEnhancer().enhance(); } if (funcs.length === 1 && typeof funcs[0] === 'object') { return compose(funcs[0]); } return compose({})(...(funcs as StoreEnhancer[])); }