mirror of
https://github.com/reduxjs/redux-devtools.git
synced 2025-07-17 03:32:40 +03:00
* Update Redux packages * Fix instrument build * Fix some test type errors * Fix redux-devtools build * Fix rtk-query-monitor build * Fix redux-devtools-app build * Fix redux-devtools-extension build * Fix redux-devtools-remote build * Fix extension build * slider-monitor-example * test-tab-demo * inspector-monitor-demo * rtk-query-monitor-demo * counter-example * todomvc-example * Fix lint * Fix instrument test types * Fix core tests * Fix rtk-query-monitor tests * Updates
628 lines
19 KiB
TypeScript
628 lines
19 KiB
TypeScript
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<S, A extends Action<string>> {
|
|
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<Action<string>>;
|
|
};
|
|
readonly stateSanitizer?: ((state: S, index?: number) => S) | undefined;
|
|
readonly actionSanitizer?:
|
|
| (<A extends Action<string>>(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<S, A extends Action<string>> {
|
|
readonly type: 'DISPATCH';
|
|
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
|
readonly action: LiftedAction<S, A, {}>;
|
|
}
|
|
|
|
type Message<S, A extends Action<string>> =
|
|
| ImportMessage
|
|
| SyncMessage
|
|
| UpdateMessage
|
|
| StartMessage
|
|
| StopMessage
|
|
| DisconnectedMessage
|
|
| ActionMessage
|
|
| DispatchMessage<S, A>;
|
|
|
|
class DevToolsEnhancer<S, A extends Action<string>, PreloadedState> {
|
|
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
|
store!: EnhancedStore<S, A, {}>;
|
|
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<A> | 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<string>, id: number) => Action)
|
|
| undefined,
|
|
nextActionId!,
|
|
),
|
|
);
|
|
}
|
|
if (type === 'ACTION') {
|
|
message.action = stringify(
|
|
!this.actionSanitizer
|
|
? action
|
|
: this.actionSanitizer(
|
|
(action as PerformAction<A>).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<S, A>) => {
|
|
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<S, A, {}>,
|
|
});
|
|
} 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<S, A>) {
|
|
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<S, A>);
|
|
}
|
|
} 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<S, A, {}>) => {
|
|
this.lastAction = action.type;
|
|
if (!this.started && this.sendOnError === 2 && this.store.liftedStore)
|
|
async(this.checkForReducerErrors);
|
|
else if ((action as PerformAction<A>).action) {
|
|
if (
|
|
this.startOn &&
|
|
!this.started &&
|
|
this.startOn.includes((action as PerformAction<A>).action.type)
|
|
)
|
|
async(this.start);
|
|
else if (
|
|
this.stopOn &&
|
|
this.started &&
|
|
this.stopOn.includes((action as PerformAction<A>).action.type)
|
|
)
|
|
async(this.stop);
|
|
else if (
|
|
this.sendOn &&
|
|
!this.started &&
|
|
this.sendOn.includes((action as PerformAction<A>).action.type)
|
|
)
|
|
async(this.send);
|
|
}
|
|
return state;
|
|
};
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
|
handleChange(state: S, liftedState: LiftedState<S, A, {}>, 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<S, A> = {}): 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<S, A, PreloadedState>,
|
|
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 <S, A extends Action<string>, PreloadedState>(
|
|
options?: Options<S, A>,
|
|
) => new DevToolsEnhancer<S, A, PreloadedState>().enhance(options);
|
|
|
|
const compose =
|
|
(options: Options<unknown, Action<string>>) =>
|
|
(...funcs: StoreEnhancer[]) =>
|
|
(...args: unknown[]) => {
|
|
const devToolsEnhancer = new DevToolsEnhancer();
|
|
|
|
function preEnhancer(createStore: StoreEnhancerStoreCreator) {
|
|
return <S, A extends Action<string>, PreloadedState>(
|
|
reducer: Reducer<S, A, PreloadedState>,
|
|
preloadedState?: PreloadedState | undefined,
|
|
) => {
|
|
devToolsEnhancer.store = createStore(reducer, preloadedState) as any;
|
|
return {
|
|
...devToolsEnhancer.store,
|
|
dispatch: (action: Action<string>) =>
|
|
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<unknown, Action<string>>] | 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[]));
|
|
}
|