diff --git a/extension/.babelrc b/extension/.babelrc index d63fe0ce..bbb465e2 100644 --- a/extension/.babelrc +++ b/extension/.babelrc @@ -1,5 +1,9 @@ { - "presets": ["@babel/preset-env", "@babel/preset-react"], + "presets": [ + "@babel/preset-env", + "@babel/preset-react", + "@babel/preset-typescript" + ], "plugins": [ ["@babel/plugin-proposal-decorators", { "legacy": true }], ["@babel/plugin-proposal-class-properties", { "loose": true }] diff --git a/extension/package.json b/extension/package.json index be107b76..94fa0b81 100644 --- a/extension/package.json +++ b/extension/package.json @@ -28,7 +28,8 @@ "test:app": "cross-env BABEL_ENV=test jest test/app", "test:chrome": "jest test/chrome", "test:electron": "jest test/electron", - "test": "npm run test:app && npm run build:extension && npm run test:chrome && npm run test:electron" + "test": "npm run test:app && npm run build:extension && npm run test:chrome && npm run test:electron", + "type-check": "tsc --noEmit" }, "dependencies": { "@redux-devtools/app": "^1.0.0-8", @@ -37,6 +38,7 @@ "@redux-devtools/serialize": "^0.3.0", "@redux-devtools/slider-monitor": "^2.0.0-8", "@redux-devtools/utils": "^1.0.0-6", + "@types/jsan": "^3.1.2", "jsan": "^3.1.13", "lodash": "^4.17.21", "react": "^16.14.0", diff --git a/extension/src/app/api/filters.js b/extension/src/app/api/filters.ts similarity index 56% rename from extension/src/app/api/filters.js rename to extension/src/app/api/filters.ts index 609dcac4..37fa4ae9 100644 --- a/extension/src/app/api/filters.js +++ b/extension/src/app/api/filters.ts @@ -1,18 +1,35 @@ import mapValues from 'lodash/mapValues'; +import { Config } from '../../browser/extension/inject/pageScript'; +import { Action } from 'redux'; +import { LiftedState, PerformAction } from '@redux-devtools/instrument'; -export const FilterState = { +export type FilterStateValue = + | 'DO_NOT_FILTER' + | 'BLACKLIST_SPECIFIC' + | 'WHITELIST_SPECIFIC'; + +export const FilterState: { [K in FilterStateValue]: FilterStateValue } = { DO_NOT_FILTER: 'DO_NOT_FILTER', BLACKLIST_SPECIFIC: 'BLACKLIST_SPECIFIC', WHITELIST_SPECIFIC: 'WHITELIST_SPECIFIC', }; -export function getLocalFilter(config) { +function isArray(arg: unknown): arg is readonly unknown[] { + return Array.isArray(arg); +} + +interface LocalFilter { + readonly whitelist: string | undefined; + readonly blacklist: string | undefined; +} + +export function getLocalFilter(config: Config): LocalFilter | undefined { if (config.actionsBlacklist || config.actionsWhitelist) { return { - whitelist: Array.isArray(config.actionsWhitelist) + whitelist: isArray(config.actionsWhitelist) ? config.actionsWhitelist.join('|') : config.actionsWhitelist, - blacklist: Array.isArray(config.actionsBlacklist) + blacklist: isArray(config.actionsBlacklist) ? config.actionsBlacklist.join('|') : config.actionsBlacklist, }; @@ -20,38 +37,48 @@ export function getLocalFilter(config) { return undefined; } -export const noFiltersApplied = (localFilter) => +export const noFiltersApplied = (localFilter: LocalFilter | undefined) => // !predicate && !localFilter && (!window.devToolsOptions || !window.devToolsOptions.filter || window.devToolsOptions.filter === FilterState.DO_NOT_FILTER); -export function isFiltered(action, localFilter) { +export function isFiltered>( + action: A | string, + localFilter: LocalFilter | undefined +) { if ( noFiltersApplied(localFilter) || - (typeof action !== 'string' && typeof action.type.match !== 'function') + (typeof action !== 'string' && + typeof (action.type as string).match !== 'function') ) { return false; } const { whitelist, blacklist } = localFilter || window.devToolsOptions || {}; - const actionType = action.type || action; + const actionType = ((action as A).type || action) as string; return ( (whitelist && !actionType.match(whitelist)) || (blacklist && actionType.match(blacklist)) ); } -function filterActions(actionsById, actionSanitizer) { +function filterActions>( + actionsById: { [p: number]: PerformAction }, + actionSanitizer: ((action: A, id: number) => A) | undefined +): { [p: number]: PerformAction } { if (!actionSanitizer) return actionsById; return mapValues(actionsById, (action, id) => ({ ...action, - action: actionSanitizer(action.action, id), + action: actionSanitizer(action.action, id as unknown as number), })); } -function filterStates(computedStates, stateSanitizer) { +function filterStates( + computedStates: { state: S; error?: string | undefined }[], + stateSanitizer: ((state: S, index: number) => S) | undefined +) { if (!stateSanitizer) return computedStates; return computedStates.map((state, idx) => ({ ...state, @@ -59,23 +86,19 @@ function filterStates(computedStates, stateSanitizer) { })); } -export function filterState( - state, - type, - localFilter, - stateSanitizer, - actionSanitizer, - nextActionId, - predicate -) { - if (type === 'ACTION') { - return !stateSanitizer ? state : stateSanitizer(state, nextActionId - 1); - } else if (type !== 'STATE') return state; - +export function filterState>( + state: LiftedState, + localFilter: LocalFilter | undefined, + stateSanitizer: ((state: S, index: number) => S) | undefined, + actionSanitizer: ((action: A, id: number) => A) | undefined, + predicate: ((state: S, action: A) => boolean) | undefined +): LiftedState { if (predicate || !noFiltersApplied(localFilter)) { - const filteredStagedActionIds = []; - const filteredComputedStates = []; - const sanitizedActionsById = actionSanitizer && {}; + const filteredStagedActionIds: number[] = []; + const filteredComputedStates: { state: S; error?: string | undefined }[] = + []; + const sanitizedActionsById: { [p: number]: PerformAction } | undefined = + actionSanitizer && {}; const { actionsById } = state; const { computedStates } = state; @@ -97,7 +120,7 @@ export function filterState( : liftedState ); if (actionSanitizer) { - sanitizedActionsById[id] = { + sanitizedActionsById![id] = { ...liftedAction, action: actionSanitizer(currAction, id), }; @@ -120,14 +143,27 @@ export function filterState( }; } -export function startingFrom( - sendingActionId, - state, - localFilter, - stateSanitizer, - actionSanitizer, - predicate -) { +export interface PartialLiftedState> { + readonly actionsById: { [actionId: number]: PerformAction }; + readonly computedStates: { state: S; error?: string }[]; + readonly stagedActionIds: readonly number[]; + readonly currentStateIndex: number; + readonly nextActionId: number; + readonly committedState?: S; +} + +export function startingFrom>( + sendingActionId: number, + state: LiftedState, + localFilter: LocalFilter | undefined, + stateSanitizer: ((state: S, index: number) => S) | undefined, + actionSanitizer: + | (>(action: A, id: number) => A) + | undefined, + predicate: + | (>(state: S, action: A) => boolean) + | undefined +): LiftedState | PartialLiftedState | undefined { const stagedActionIds = state.stagedActionIds; if (sendingActionId <= stagedActionIds[1]) return state; const index = stagedActionIds.indexOf(sendingActionId); @@ -137,7 +173,7 @@ export function startingFrom( const filteredStagedActionIds = shouldFilter ? [0] : stagedActionIds; const actionsById = state.actionsById; const computedStates = state.computedStates; - const newActionsById = {}; + const newActionsById: { [key: number]: PerformAction } = {}; const newComputedStates = []; let key; let currAction; diff --git a/extension/src/app/api/generateInstanceId.js b/extension/src/app/api/generateInstanceId.js deleted file mode 100644 index 4471d9cb..00000000 --- a/extension/src/app/api/generateInstanceId.js +++ /dev/null @@ -1,5 +0,0 @@ -let id = 0; - -export default function generateId(instanceId) { - return instanceId || ++id; -} diff --git a/extension/src/app/api/generateInstanceId.ts b/extension/src/app/api/generateInstanceId.ts new file mode 100644 index 00000000..ae121568 --- /dev/null +++ b/extension/src/app/api/generateInstanceId.ts @@ -0,0 +1,5 @@ +let id = 0; + +export default function generateId(instanceId: number | undefined) { + return instanceId || ++id; +} diff --git a/extension/src/app/api/importState.js b/extension/src/app/api/importState.js deleted file mode 100644 index 02ae7447..00000000 --- a/extension/src/app/api/importState.js +++ /dev/null @@ -1,74 +0,0 @@ -import mapValues from 'lodash/mapValues'; -import jsan from 'jsan'; -import seralizeImmutable from '@redux-devtools/serialize/lib/immutable/serialize'; - -function deprecate(param) { - // eslint-disable-next-line no-console - console.warn( - `\`${param}\` parameter for Redux DevTools Extension is deprecated. Use \`serialize\` parameter instead: https://github.com/zalmoxisus/redux-devtools-extension/releases/tag/v2.12.1` - ); -} - -export default function importState( - state, - { deserializeState, deserializeAction, serialize } -) { - if (!state) return undefined; - let parse = jsan.parse; - if (serialize) { - if (serialize.immutable) { - parse = (v) => - jsan.parse( - v, - seralizeImmutable( - serialize.immutable, - serialize.refs, - serialize.replacer, - serialize.reviver - ).reviver - ); - } else if (serialize.reviver) { - parse = (v) => jsan.parse(v, serialize.reviver); - } - } - - let preloadedState; - let nextLiftedState = parse(state); - if (nextLiftedState.payload) { - if (nextLiftedState.preloadedState) { - preloadedState = parse(nextLiftedState.preloadedState); - } - nextLiftedState = parse(nextLiftedState.payload); - } - if (deserializeState) { - deprecate('deserializeState'); - if (typeof nextLiftedState.computedStates !== 'undefined') { - nextLiftedState.computedStates = nextLiftedState.computedStates.map( - (computedState) => ({ - ...computedState, - state: deserializeState(computedState.state), - }) - ); - } - if (typeof nextLiftedState.committedState !== 'undefined') { - nextLiftedState.committedState = deserializeState( - nextLiftedState.committedState - ); - } - if (typeof preloadedState !== 'undefined') { - preloadedState = deserializeState(preloadedState); - } - } - if (deserializeAction) { - deprecate('deserializeAction'); - nextLiftedState.actionsById = mapValues( - nextLiftedState.actionsById, - (liftedAction) => ({ - ...liftedAction, - action: deserializeAction(liftedAction.action), - }) - ); - } - - return { nextLiftedState, preloadedState }; -} diff --git a/extension/src/app/api/importState.ts b/extension/src/app/api/importState.ts new file mode 100644 index 00000000..ba2bc7d5 --- /dev/null +++ b/extension/src/app/api/importState.ts @@ -0,0 +1,116 @@ +import mapValues from 'lodash/mapValues'; +import jsan from 'jsan'; +import seralizeImmutable from '@redux-devtools/serialize/lib/immutable/serialize'; +import { + Config, + SerializeWithImmutable, +} from '../../browser/extension/inject/pageScript'; +import Immutable from 'immutable'; +import { LiftedState } from '@redux-devtools/instrument'; +import { Action } from 'redux'; + +function deprecate(param: string) { + // eslint-disable-next-line no-console + console.warn( + `\`${param}\` parameter for Redux DevTools Extension is deprecated. Use \`serialize\` parameter instead: https://github.com/zalmoxisus/redux-devtools-extension/releases/tag/v2.12.1` + ); +} + +interface SerializeWithRequiredImmutable extends SerializeWithImmutable { + readonly immutable: typeof Immutable; +} + +function isSerializeWithImmutable( + serialize: boolean | SerializeWithImmutable +): serialize is SerializeWithRequiredImmutable { + return !!(serialize as SerializeWithImmutable).immutable; +} + +interface SerializeWithRequiredReviver extends SerializeWithImmutable { + readonly reviver: (key: string, value: unknown) => unknown; +} + +function isSerializeWithReviver( + serialize: boolean | SerializeWithImmutable +): serialize is SerializeWithRequiredReviver { + return !!(serialize as SerializeWithImmutable).immutable; +} + +interface ParsedSerializedLiftedState { + readonly payload: string; + readonly preloadedState?: string; +} + +export default function importState>( + state: string | undefined, + { deserializeState, deserializeAction, serialize }: Config +) { + if (!state) return undefined; + let parse = jsan.parse; + if (serialize) { + if (isSerializeWithImmutable(serialize)) { + parse = (v) => + jsan.parse( + v, + seralizeImmutable( + serialize.immutable, + serialize.refs, + serialize.replacer, + serialize.reviver + ).reviver + ); + } else if (isSerializeWithReviver(serialize)) { + parse = (v) => jsan.parse(v, serialize.reviver); + } + } + + const parsedSerializedLiftedState: + | ParsedSerializedLiftedState + | LiftedState = parse(state) as + | ParsedSerializedLiftedState + | LiftedState; + let preloadedState = + 'payload' in parsedSerializedLiftedState && + parsedSerializedLiftedState.preloadedState + ? (parse(parsedSerializedLiftedState.preloadedState) as S) + : undefined; + const nextLiftedState = + 'payload' in parsedSerializedLiftedState + ? (parse(parsedSerializedLiftedState.payload) as LiftedState< + S, + A, + unknown + >) + : parsedSerializedLiftedState; + if (deserializeState) { + deprecate('deserializeState'); + if (typeof nextLiftedState.computedStates !== 'undefined') { + nextLiftedState.computedStates = nextLiftedState.computedStates.map( + (computedState) => ({ + ...computedState, + state: deserializeState(computedState.state), + }) + ); + } + if (typeof nextLiftedState.committedState !== 'undefined') { + nextLiftedState.committedState = deserializeState( + nextLiftedState.committedState + ); + } + if (typeof preloadedState !== 'undefined') { + preloadedState = deserializeState(preloadedState); + } + } + if (deserializeAction) { + deprecate('deserializeAction'); + nextLiftedState.actionsById = mapValues( + nextLiftedState.actionsById, + (liftedAction) => ({ + ...liftedAction, + action: deserializeAction(liftedAction.action), + }) + ); + } + + return { nextLiftedState, preloadedState }; +} diff --git a/extension/src/app/api/index.js b/extension/src/app/api/index.js deleted file mode 100644 index 5590dbfb..00000000 --- a/extension/src/app/api/index.js +++ /dev/null @@ -1,393 +0,0 @@ -import jsan from 'jsan'; -import throttle from 'lodash/throttle'; -import seralizeImmutable from '@redux-devtools/serialize/lib/immutable/serialize'; -import { getActionsArray } from '@redux-devtools/utils'; -import { getLocalFilter, isFiltered } from './filters'; -import importState from './importState'; -import generateId from './generateInstanceId'; - -const listeners = {}; -export const source = '@devtools-page'; - -function windowReplacer(key, value) { - if (value && value.window === value) { - return '[WINDOW]'; - } - return value; -} - -function tryCatchStringify(obj) { - try { - return JSON.stringify(obj); - } catch (err) { - /* eslint-disable no-console */ - if (process.env.NODE_ENV !== 'production') { - console.log('Failed to stringify', err); - } - /* eslint-enable no-console */ - return jsan.stringify(obj, windowReplacer, null, { - circular: '[CIRCULAR]', - date: true, - }); - } -} - -let stringifyWarned; -function stringify(obj, serialize) { - const str = - typeof serialize === 'undefined' - ? tryCatchStringify(obj) - : jsan.stringify(obj, serialize.replacer, null, serialize.options); - - if (!stringifyWarned && str && str.length > 16 * 1024 * 1024) { - // 16 MB - /* eslint-disable no-console */ - console.warn( - 'Application state or actions payloads are too large making Redux DevTools serialization slow and consuming a lot of memory. See https://git.io/fpcP5 on how to configure it.' - ); - /* eslint-enable no-console */ - stringifyWarned = true; - } - - return str; -} - -export function getSeralizeParameter(config, param) { - const serialize = config.serialize; - if (serialize) { - if (serialize === true) return { options: true }; - if (serialize.immutable) { - const immutableSerializer = seralizeImmutable( - serialize.immutable, - serialize.refs, - serialize.replacer, - serialize.reviver - ); - return { - replacer: immutableSerializer.replacer, - reviver: immutableSerializer.reviver, - options: - typeof serialize.options === 'object' - ? { ...immutableSerializer.options, ...serialize.options } - : immutableSerializer.options, - }; - } - if (!serialize.replacer && !serialize.reviver) { - return { options: serialize.options }; - } - return { - replacer: serialize.replacer, - reviver: serialize.reviver, - options: serialize.options || true, - }; - } - - const value = config[param]; - if (typeof value === 'undefined') return undefined; - // eslint-disable-next-line no-console - console.warn( - `\`${param}\` parameter for Redux DevTools Extension is deprecated. Use \`serialize\` parameter instead: https://github.com/zalmoxisus/redux-devtools-extension/releases/tag/v2.12.1` - ); - - if (typeof serializeState === 'boolean') return { options: value }; - if (typeof serializeState === 'function') return { replacer: value }; - return value; -} - -function post(message) { - window.postMessage(message, '*'); -} - -function getStackTrace(config, toExcludeFromTrace) { - if (!config.trace) return undefined; - if (typeof config.trace === 'function') return config.trace(); - - let stack; - let extraFrames = 0; - let prevStackTraceLimit; - const traceLimit = config.traceLimit; - const error = Error(); - if (Error.captureStackTrace) { - if (Error.stackTraceLimit < traceLimit) { - prevStackTraceLimit = Error.stackTraceLimit; - Error.stackTraceLimit = traceLimit; - } - Error.captureStackTrace(error, toExcludeFromTrace); - } else { - extraFrames = 3; - } - stack = error.stack; - if (prevStackTraceLimit) Error.stackTraceLimit = prevStackTraceLimit; - if ( - extraFrames || - typeof Error.stackTraceLimit !== 'number' || - Error.stackTraceLimit > traceLimit - ) { - const frames = stack.split('\n'); - if (frames.length > traceLimit) { - stack = frames - .slice(0, traceLimit + extraFrames + (frames[0] === 'Error' ? 1 : 0)) - .join('\n'); - } - } - return stack; -} - -function amendActionType(action, config, toExcludeFromTrace) { - let timestamp = Date.now(); - let stack = getStackTrace(config, toExcludeFromTrace); - if (typeof action === 'string') { - return { action: { type: action }, timestamp, stack }; - } - if (!action.type) return { action: { type: 'update' }, timestamp, stack }; - if (action.action) return stack ? { stack, ...action } : action; - return { action, timestamp, stack }; -} - -export function toContentScript(message, serializeState, serializeAction) { - if (message.type === 'ACTION') { - message.action = stringify(message.action, serializeAction); - message.payload = stringify(message.payload, serializeState); - } else if (message.type === 'STATE' || message.type === 'PARTIAL_STATE') { - const { actionsById, computedStates, committedState, ...rest } = - message.payload; - message.payload = rest; - message.actionsById = stringify(actionsById, serializeAction); - message.computedStates = stringify(computedStates, serializeState); - message.committedState = typeof committedState !== 'undefined'; - } else if (message.type === 'EXPORT') { - message.payload = stringify(message.payload, serializeAction); - if (typeof message.committedState !== 'undefined') { - message.committedState = stringify( - message.committedState, - serializeState - ); - } - } - post(message); -} - -export function sendMessage(action, state, config, instanceId, name) { - let amendedAction = action; - if (typeof config !== 'object') { - // Legacy: sending actions not from connected part - config = {}; // eslint-disable-line no-param-reassign - if (action) amendedAction = amendActionType(action, config, sendMessage); - } - const message = { - type: action ? 'ACTION' : 'STATE', - action: amendedAction, - payload: state, - maxAge: config.maxAge, - source, - name: config.name || name, - instanceId: config.instanceId || instanceId || 1, - }; - toContentScript(message, config.serialize, config.serialize); -} - -function handleMessages(event) { - if (process.env.BABEL_ENV !== 'test' && (!event || event.source !== window)) { - return; - } - const message = event.data; - if (!message || message.source !== '@devtools-extension') return; - Object.keys(listeners).forEach((id) => { - if (message.id && id !== message.id) return; - if (typeof listeners[id] === 'function') listeners[id](message); - else { - listeners[id].forEach((fn) => { - fn(message); - }); - } - }); -} - -export function setListener(onMessage, instanceId) { - listeners[instanceId] = onMessage; - window.addEventListener('message', handleMessages, false); -} - -const liftListener = (listener, config) => (message) => { - let data = {}; - if (message.type === 'IMPORT') { - data.type = 'DISPATCH'; - data.payload = { - type: 'IMPORT_STATE', - ...importState(message.state, config), - }; - } else { - data = message; - } - listener(data); -}; - -export function disconnect() { - window.removeEventListener('message', handleMessages); - post({ type: 'DISCONNECT', source }); -} - -export function connect(preConfig) { - const config = preConfig || {}; - const id = generateId(config.instanceId); - if (!config.instanceId) config.instanceId = id; - if (!config.name) { - config.name = - document.title && id === 1 ? document.title : `Instance ${id}`; - } - if (config.serialize) config.serialize = getSeralizeParameter(config); - const actionCreators = config.actionCreators || {}; - const latency = config.latency; - const predicate = config.predicate; - const localFilter = getLocalFilter(config); - const autoPause = config.autoPause; - let isPaused = autoPause; - let delayedActions = []; - let delayedStates = []; - - const rootListiner = (action) => { - if (autoPause) { - if (action.type === 'START') isPaused = false; - else if (action.type === 'STOP') isPaused = true; - } - if (action.type === 'DISPATCH') { - const payload = action.payload; - if (payload.type === 'PAUSE_RECORDING') { - isPaused = payload.status; - toContentScript({ - type: 'LIFTED', - liftedState: { isPaused }, - instanceId: id, - source, - }); - } - } - }; - - listeners[id] = [rootListiner]; - - const subscribe = (listener) => { - if (!listener) return undefined; - const liftedListener = liftListener(listener, config); - listeners[id].push(liftedListener); - - return function unsubscribe() { - const index = listeners[id].indexOf(liftedListener); - listeners[id].splice(index, 1); - }; - }; - - const unsubscribe = () => { - delete listeners[id]; - }; - - const sendDelayed = throttle(() => { - sendMessage(delayedActions, delayedStates, config); - delayedActions = []; - delayedStates = []; - }, latency); - - const send = (action, state) => { - if ( - isPaused || - isFiltered(action, localFilter) || - (predicate && !predicate(state, action)) - ) { - return; - } - - let amendedAction = action; - const amendedState = config.stateSanitizer - ? config.stateSanitizer(state) - : state; - if (action) { - if (config.getActionType) { - amendedAction = config.getActionType(action); - if (typeof amendedAction !== 'object') { - amendedAction = { - action: { type: amendedAction }, - timestamp: Date.now(), - }; - } - } else if (config.actionSanitizer) { - amendedAction = config.actionSanitizer(action); - } - amendedAction = amendActionType(amendedAction, config, send); - if (latency) { - delayedActions.push(amendedAction); - delayedStates.push(amendedState); - sendDelayed(); - return; - } - } - sendMessage(amendedAction, amendedState, config); - }; - - const init = (state, liftedData) => { - const message = { - type: 'INIT', - payload: stringify(state, config.serialize), - instanceId: id, - source, - }; - if (liftedData && Array.isArray(liftedData)) { - // Legacy - message.action = stringify(liftedData); - message.name = config.name; - } else { - if (liftedData) { - message.liftedState = liftedData; - if (liftedData.isPaused) isPaused = true; - } - message.libConfig = { - actionCreators: JSON.stringify(getActionsArray(actionCreators)), - name: config.name || document.title, - features: config.features, - serialize: !!config.serialize, - type: config.type, - }; - } - post(message); - }; - - const error = (payload) => { - post({ type: 'ERROR', payload, id, source }); - }; - - window.addEventListener('message', handleMessages, false); - - post({ type: 'INIT_INSTANCE', instanceId: id, source }); - - return { - init, - subscribe, - unsubscribe, - send, - error, - }; -} - -export function updateStore(stores) { - return function (newStore, instanceId) { - /* eslint-disable no-console */ - console.warn( - '`__REDUX_DEVTOOLS_EXTENSION__.updateStore` is deprecated, remove it and just use ' + - "`__REDUX_DEVTOOLS_EXTENSION_COMPOSE__` instead of the extension's store enhancer: " + - 'https://github.com/zalmoxisus/redux-devtools-extension#12-advanced-store-setup' - ); - /* eslint-enable no-console */ - const store = stores[instanceId || Object.keys(stores)[0]]; - // Mutate the store in order to keep the reference - store.liftedStore = newStore.liftedStore; - store.getState = newStore.getState; - store.dispatch = newStore.dispatch; - }; -} - -export function isInIframe() { - try { - return window.self !== window.top; - } catch (e) { - return true; - } -} diff --git a/extension/src/app/api/index.ts b/extension/src/app/api/index.ts new file mode 100644 index 00000000..b4ca5594 --- /dev/null +++ b/extension/src/app/api/index.ts @@ -0,0 +1,736 @@ +import jsan, { Options } from 'jsan'; +import throttle from 'lodash/throttle'; +import serializeImmutable from '@redux-devtools/serialize/lib/immutable/serialize'; +import { getActionsArray } from '@redux-devtools/utils'; +import { getLocalFilter, isFiltered, PartialLiftedState } from './filters'; +import importState from './importState'; +import generateId from './generateInstanceId'; +import { Config } from '../../browser/extension/inject/pageScript'; +import { Action } from 'redux'; +import { + EnhancedStore, + LiftedState, + PerformAction, +} from '@redux-devtools/instrument'; +import { LibConfig } from '@redux-devtools/app/lib/actions'; +import { + ContentScriptToPageScriptMessage, + ListenerMessage, +} from '../../browser/extension/inject/contentScript'; +import { Position } from './openWindow'; + +const listeners: { + [instanceId: string]: + | ((message: ContentScriptToPageScriptMessage) => void) + | ((message: ContentScriptToPageScriptMessage) => void)[]; +} = {}; +export const source = '@devtools-page'; + +function windowReplacer(key: string, value: unknown) { + if (value && (value as Window).window === value) { + return '[WINDOW]'; + } + return value; +} + +function tryCatchStringify(obj: unknown) { + try { + return JSON.stringify(obj); + } catch (err) { + /* eslint-disable no-console */ + if (process.env.NODE_ENV !== 'production') { + console.log('Failed to stringify', err); + } + /* eslint-enable no-console */ + return jsan.stringify(obj, windowReplacer, undefined, { + circular: '[CIRCULAR]', + date: true, + }); + } +} + +let stringifyWarned: boolean; +function stringify(obj: unknown, serialize?: Serialize | undefined) { + const str = + typeof serialize === 'undefined' + ? tryCatchStringify(obj) + : jsan.stringify(obj, serialize.replacer, undefined, serialize.options); + + if (!stringifyWarned && str && str.length > 16 * 1024 * 1024) { + // 16 MB + /* eslint-disable no-console */ + console.warn( + 'Application state or actions payloads are too large making Redux DevTools serialization slow and consuming a lot of memory. See https://git.io/fpcP5 on how to configure it.' + ); + /* eslint-enable no-console */ + stringifyWarned = true; + } + + return str; +} + +export interface Serialize { + readonly replacer?: (key: string, value: unknown) => unknown; + readonly reviver?: (key: string, value: unknown) => unknown; + readonly options?: Options | boolean; +} + +export function getSerializeParameter( + config: Config, + param?: 'serializeState' | 'serializeAction' +) { + const serialize = config.serialize; + if (serialize) { + if (serialize === true) return { options: true }; + if (serialize.immutable) { + const immutableSerializer = serializeImmutable( + serialize.immutable, + serialize.refs, + serialize.replacer, + serialize.reviver + ); + return { + replacer: immutableSerializer.replacer, + reviver: immutableSerializer.reviver, + options: + typeof serialize.options === 'object' + ? { ...immutableSerializer.options, ...serialize.options } + : immutableSerializer.options, + }; + } + if (!serialize.replacer && !serialize.reviver) { + return { options: serialize.options }; + } + return { + replacer: serialize.replacer, + reviver: serialize.reviver, + options: serialize.options || true, + }; + } + + const value = config[param!]; + if (typeof value === 'undefined') return undefined; + // eslint-disable-next-line no-console + console.warn( + `\`${param}\` parameter for Redux DevTools Extension is deprecated. Use \`serialize\` parameter instead: https://github.com/zalmoxisus/redux-devtools-extension/releases/tag/v2.12.1` + ); + + if (typeof value === 'boolean') return { options: value }; + if (typeof value === 'function') return { replacer: value }; + return value; +} + +interface InitInstancePageScriptToContentScriptMessage { + readonly type: 'INIT_INSTANCE'; + readonly instanceId: number; + readonly source: typeof source; +} + +interface DisconnectMessage { + readonly type: 'DISCONNECT'; + readonly source: typeof source; +} + +interface InitMessage> { + readonly type: 'INIT'; + readonly payload: string; + readonly instanceId: number; + readonly source: typeof source; + action?: string; + name?: string | undefined; + liftedState?: LiftedState; + libConfig?: LibConfig; +} + +interface SerializedPartialLiftedState { + readonly stagedActionIds: readonly number[]; + readonly currentStateIndex: number; + readonly nextActionId: number; +} + +interface SerializedPartialStateMessage { + readonly type: 'PARTIAL_STATE'; + readonly payload: SerializedPartialLiftedState; + readonly source: typeof source; + readonly instanceId: number; + readonly maxAge: number; + readonly actionsById: string; + readonly computedStates: string; + readonly committedState: boolean; +} + +interface SerializedExportMessage { + readonly type: 'EXPORT'; + readonly payload: string; + readonly committedState: string | undefined; + readonly source: typeof source; + readonly instanceId: number; +} + +interface SerializedActionMessage { + readonly type: 'ACTION'; + readonly payload: string; + readonly source: typeof source; + readonly instanceId: number; + readonly action: string; + readonly maxAge: number; + readonly nextActionId?: number; +} + +interface SerializedStateMessage> { + readonly type: 'STATE'; + readonly payload: Omit< + LiftedState, + 'actionsById' | 'computedStates' | 'committedState' + >; + readonly source: typeof source; + readonly instanceId: number; + readonly libConfig?: LibConfig; + readonly actionsById: string; + readonly computedStates: string; + readonly committedState: boolean; +} + +interface OpenMessage { + readonly source: typeof source; + readonly type: 'OPEN'; + readonly position: Position; +} + +export type PageScriptToContentScriptMessageForwardedToMonitors< + S, + A extends Action +> = + | InitMessage + | LiftedMessage + | SerializedPartialStateMessage + | SerializedExportMessage + | SerializedActionMessage + | SerializedStateMessage; + +export type PageScriptToContentScriptMessageWithoutDisconnectOrInitInstance< + S, + A extends Action +> = + | PageScriptToContentScriptMessageForwardedToMonitors + | ErrorMessage + | GetReportMessage + | StopMessage + | OpenMessage; + +export type PageScriptToContentScriptMessageWithoutDisconnect< + S, + A extends Action +> = + | PageScriptToContentScriptMessageWithoutDisconnectOrInitInstance + | InitInstancePageScriptToContentScriptMessage + | InitInstanceMessage; + +export type PageScriptToContentScriptMessage> = + | PageScriptToContentScriptMessageWithoutDisconnect + | DisconnectMessage; + +function post>( + message: PageScriptToContentScriptMessage +) { + window.postMessage(message, '*'); +} + +function getStackTrace( + config: Config, + toExcludeFromTrace: Function | undefined +) { + if (!config.trace) return undefined; + if (typeof config.trace === 'function') return config.trace(); + + let stack; + let extraFrames = 0; + let prevStackTraceLimit; + const traceLimit = config.traceLimit; + const error = Error(); + if (Error.captureStackTrace) { + if (Error.stackTraceLimit < traceLimit!) { + prevStackTraceLimit = Error.stackTraceLimit; + Error.stackTraceLimit = traceLimit!; + } + Error.captureStackTrace(error, toExcludeFromTrace); + } else { + extraFrames = 3; + } + stack = error.stack; + if (prevStackTraceLimit) Error.stackTraceLimit = prevStackTraceLimit; + if ( + extraFrames || + typeof Error.stackTraceLimit !== 'number' || + Error.stackTraceLimit > traceLimit! + ) { + const frames = stack!.split('\n'); + if (frames.length > traceLimit!) { + stack = frames + .slice(0, traceLimit! + extraFrames + (frames[0] === 'Error' ? 1 : 0)) + .join('\n'); + } + } + return stack; +} + +function amendActionType>( + action: + | A + | StructuralPerformAction + | StructuralPerformAction[] + | string, + config: Config, + toExcludeFromTrace: Function | undefined +): StructuralPerformAction { + let timestamp = Date.now(); + let stack = getStackTrace(config, toExcludeFromTrace); + if (typeof action === 'string') { + return { action: { type: action } as A, timestamp, stack }; + } + if (!(action as A).type) + return { action: { type: 'update' } as A, timestamp, stack }; + if ((action as StructuralPerformAction).action) + return ( + stack ? { stack, ...action } : action + ) as StructuralPerformAction; + return { action, timestamp, stack } as StructuralPerformAction; +} + +interface LiftedMessage { + readonly type: 'LIFTED'; + readonly liftedState: { readonly isPaused: boolean | undefined }; + readonly instanceId: number; + readonly source: typeof source; +} + +interface PartialStateMessage> { + readonly type: 'PARTIAL_STATE'; + readonly payload: PartialLiftedState; + readonly source: typeof source; + readonly instanceId: number; + readonly maxAge: number; +} + +interface ExportMessage> { + readonly type: 'EXPORT'; + readonly payload: readonly A[]; + readonly committedState: S; + readonly source: typeof source; + readonly instanceId: number; +} + +export interface StructuralPerformAction> { + readonly action: A; + readonly timestamp?: number; + readonly stack?: string; +} + +type SingleUserAction> = + | PerformAction + | StructuralPerformAction + | A; +type UserAction> = + | SingleUserAction + | readonly SingleUserAction[]; + +interface ActionMessage> { + readonly type: 'ACTION'; + readonly payload: S; + readonly source: typeof source; + readonly instanceId: number; + readonly action: UserAction; + readonly maxAge: number; + readonly nextActionId?: number; + readonly name?: string; +} + +interface StateMessage> { + readonly type: 'STATE'; + readonly payload: LiftedState; + readonly source: typeof source; + readonly instanceId: number; + readonly libConfig?: LibConfig; + readonly action?: UserAction; + readonly maxAge?: number; + readonly name?: string; +} + +export interface ErrorMessage { + readonly type: 'ERROR'; + readonly payload: string; + readonly source: typeof source; + readonly instanceId: number; + readonly message?: string | undefined; +} + +interface InitInstanceMessage { + readonly type: 'INIT_INSTANCE'; + readonly payload: undefined; + readonly source: typeof source; + readonly instanceId: number; +} + +interface GetReportMessage { + readonly type: 'GET_REPORT'; + readonly payload: string; + readonly source: typeof source; + readonly instanceId: number; +} + +interface StopMessage { + readonly type: 'STOP'; + readonly payload: undefined; + readonly source: typeof source; + readonly instanceId: number; +} + +type ToContentScriptMessage> = + | LiftedMessage + | PartialStateMessage + | ExportMessage + | ActionMessage + | StateMessage + | ErrorMessage + | InitInstanceMessage + | GetReportMessage + | StopMessage; + +export function toContentScript>( + message: ToContentScriptMessage, + serializeState?: Serialize | undefined, + serializeAction?: Serialize | undefined +) { + if (message.type === 'ACTION') { + post({ + ...message, + action: stringify(message.action, serializeAction), + payload: stringify(message.payload, serializeState), + }); + } else if (message.type === 'STATE') { + const { actionsById, computedStates, committedState, ...rest } = + message.payload; + post({ + ...message, + payload: rest, + actionsById: stringify(actionsById, serializeAction), + computedStates: stringify(computedStates, serializeState), + committedState: typeof committedState !== 'undefined', + }); + } else if (message.type === 'PARTIAL_STATE') { + const { actionsById, computedStates, committedState, ...rest } = + message.payload; + post({ + ...message, + payload: rest, + actionsById: stringify(actionsById, serializeAction), + computedStates: stringify(computedStates, serializeState), + committedState: typeof committedState !== 'undefined', + }); + } else if (message.type === 'EXPORT') { + post({ + ...message, + payload: stringify(message.payload, serializeAction), + committedState: + typeof message.committedState !== 'undefined' + ? stringify(message.committedState, serializeState) + : (message.committedState as undefined), + }); + } else { + post(message); + } +} + +export function sendMessage>( + action: StructuralPerformAction | StructuralPerformAction[], + state: LiftedState, + config: Config, + instanceId?: number, + name?: string +) { + let amendedAction = action; + if (typeof config !== 'object') { + // Legacy: sending actions not from connected part + config = {}; // eslint-disable-line no-param-reassign + if (action) amendedAction = amendActionType(action, config, sendMessage); + } + if (action) { + toContentScript( + { + type: 'ACTION', + action: amendedAction, + payload: state, + maxAge: config.maxAge!, + source, + name: config.name || name, + instanceId: config.instanceId || instanceId || 1, + }, + config.serialize as Serialize | undefined, + config.serialize as Serialize | undefined + ); + } else { + toContentScript( + { + type: 'STATE', + action: amendedAction, + payload: state, + maxAge: config.maxAge, + source, + name: config.name || name, + instanceId: config.instanceId || instanceId || 1, + }, + config.serialize as Serialize | undefined, + config.serialize as Serialize | undefined + ); + } +} + +function handleMessages(event: MessageEvent) { + if (process.env.BABEL_ENV !== 'test' && (!event || event.source !== window)) { + return; + } + const message = event.data; + if (!message || message.source !== '@devtools-extension') return; + Object.keys(listeners).forEach((id) => { + if (message.id && id !== message.id) return; + const listenersForId = listeners[id]; + if (typeof listenersForId === 'function') listenersForId(message); + else { + listenersForId.forEach((fn) => { + fn(message); + }); + } + }); +} + +export function setListener( + onMessage: (message: ContentScriptToPageScriptMessage) => void, + instanceId: number +) { + listeners[instanceId] = onMessage; + window.addEventListener('message', handleMessages, false); +} + +const liftListener = + >( + listener: (message: ListenerMessage) => void, + config: Config + ) => + (message: ContentScriptToPageScriptMessage) => { + if (message.type === 'IMPORT') { + listener({ + type: 'DISPATCH', + payload: { + type: 'IMPORT_STATE', + ...importState(message.state, config)!, + }, + }); + } else { + listener(message); + } + }; + +export function disconnect() { + window.removeEventListener('message', handleMessages); + post({ type: 'DISCONNECT', source }); +} + +export interface ConnectResponse { + init: >( + state: S, + liftedData: LiftedState + ) => void; + subscribe: >( + listener: (message: ListenerMessage) => void + ) => (() => void) | undefined; + unsubscribe: () => void; + send: >( + action: A, + state: LiftedState + ) => void; + error: (payload: string) => void; +} + +export function connect(preConfig: Config): ConnectResponse { + const config = preConfig || {}; + const id = generateId(config.instanceId); + if (!config.instanceId) config.instanceId = id; + if (!config.name) { + config.name = + document.title && id === 1 ? document.title : `Instance ${id}`; + } + if (config.serialize) config.serialize = getSerializeParameter(config); + const actionCreators = config.actionCreators || {}; + const latency = config.latency; + const predicate = config.predicate; + const localFilter = getLocalFilter(config); + const autoPause = config.autoPause; + let isPaused = autoPause; + let delayedActions: StructuralPerformAction>[] = []; + let delayedStates: LiftedState, unknown>[] = []; + + const rootListener = (action: ContentScriptToPageScriptMessage) => { + if (autoPause) { + if (action.type === 'START') isPaused = false; + else if (action.type === 'STOP') isPaused = true; + } + if (action.type === 'DISPATCH') { + const payload = action.payload; + if (payload.type === 'PAUSE_RECORDING') { + isPaused = payload.status; + toContentScript({ + type: 'LIFTED', + liftedState: { isPaused }, + instanceId: id, + source, + }); + } + } + }; + + listeners[id] = [rootListener]; + + const subscribe = >( + listener: (message: ListenerMessage) => void + ) => { + if (!listener) return undefined; + const liftedListener = liftListener(listener, config); + const listenersForId = listeners[id] as (( + message: ContentScriptToPageScriptMessage + ) => void)[]; + listenersForId.push(liftedListener); + + return function unsubscribe() { + const index = listenersForId.indexOf(liftedListener); + listenersForId.splice(index, 1); + }; + }; + + const unsubscribe = () => { + delete listeners[id]; + }; + + const sendDelayed = throttle(() => { + sendMessage(delayedActions, delayedStates as any, config); + delayedActions = []; + delayedStates = []; + }, latency); + + const send = >( + action: A, + state: LiftedState + ) => { + if ( + isPaused || + isFiltered(action, localFilter) || + (predicate && !predicate(state, action)) + ) { + return; + } + + let amendedAction: A | StructuralPerformAction = action; + const amendedState = config.stateSanitizer + ? config.stateSanitizer(state) + : state; + if (action) { + if (config.getActionType) { + amendedAction = config.getActionType(action); + if (typeof amendedAction !== 'object') { + amendedAction = { + action: { type: amendedAction }, + timestamp: Date.now(), + } as unknown as A; + } + } else if (config.actionSanitizer) { + amendedAction = config.actionSanitizer(action); + } + amendedAction = amendActionType(amendedAction, config, send); + if (latency) { + delayedActions.push(amendedAction); + delayedStates.push(amendedState); + sendDelayed(); + return; + } + } + sendMessage( + amendedAction as StructuralPerformAction, + amendedState, + config + ); + }; + + const init = >( + state: S, + liftedData: LiftedState + ) => { + const message: InitMessage = { + type: 'INIT', + payload: stringify(state, config.serialize as Serialize | undefined), + instanceId: id, + source, + }; + if (liftedData && Array.isArray(liftedData)) { + // Legacy + message.action = stringify(liftedData); + message.name = config.name; + } else { + if (liftedData) { + message.liftedState = liftedData; + if (liftedData.isPaused) isPaused = true; + } + message.libConfig = { + actionCreators: JSON.stringify(getActionsArray(actionCreators)), + name: config.name || document.title, + features: config.features, + serialize: !!config.serialize, + type: config.type, + }; + } + post(message); + }; + + const error = (payload: string) => { + post({ type: 'ERROR', payload, instanceId: id, source }); + }; + + window.addEventListener('message', handleMessages, false); + + post({ type: 'INIT_INSTANCE', instanceId: id, source }); + + return { + init, + subscribe, + unsubscribe, + send, + error, + }; +} + +export function updateStore>( + stores: { + [K in string | number]: EnhancedStore; + } +) { + return function (newStore: EnhancedStore, instanceId: number) { + /* eslint-disable no-console */ + console.warn( + '`__REDUX_DEVTOOLS_EXTENSION__.updateStore` is deprecated, remove it and just use ' + + "`__REDUX_DEVTOOLS_EXTENSION_COMPOSE__` instead of the extension's store enhancer: " + + 'https://github.com/zalmoxisus/redux-devtools-extension#12-advanced-store-setup' + ); + /* eslint-enable no-console */ + const store = stores[instanceId || Object.keys(stores)[0]]; + // Mutate the store in order to keep the reference + store.liftedStore = newStore.liftedStore; + store.getState = newStore.getState; + store.dispatch = newStore.dispatch; + }; +} + +export function isInIframe() { + try { + return window.self !== window.top; + } catch (e) { + return true; + } +} diff --git a/extension/src/app/api/notifyErrors.js b/extension/src/app/api/notifyErrors.ts similarity index 74% rename from extension/src/app/api/notifyErrors.js rename to extension/src/app/api/notifyErrors.ts index 49235a42..569cbb3a 100644 --- a/extension/src/app/api/notifyErrors.js +++ b/extension/src/app/api/notifyErrors.ts @@ -1,9 +1,9 @@ -let handleError; +let handleError: (() => boolean) | undefined; let lastTime = 0; -function createExpBackoffTimer(step) { +function createExpBackoffTimer(step: number) { let count = 1; - return function (reset) { + return function (reset?: boolean) { // Reset call if (reset) { count = 1; @@ -18,7 +18,7 @@ function createExpBackoffTimer(step) { const nextErrorTimeout = createExpBackoffTimer(5000); -function postError(message) { +function postError(message: string) { if (handleError && !handleError()) return; window.postMessage( { @@ -30,7 +30,7 @@ function postError(message) { ); } -function catchErrors(e) { +function catchErrors(e: ErrorEvent) { if ( (window.devToolsOptions && !window.devToolsOptions.shouldCatchErrors) || e.timeStamp - lastTime < nextErrorTimeout() @@ -42,7 +42,7 @@ function catchErrors(e) { postError(e.message); } -export default function notifyErrors(onError) { +export default function notifyErrors(onError?: () => boolean) { handleError = onError; window.addEventListener('error', catchErrors, false); } diff --git a/extension/src/app/api/openWindow.js b/extension/src/app/api/openWindow.js deleted file mode 100644 index 58ed5344..00000000 --- a/extension/src/app/api/openWindow.js +++ /dev/null @@ -1,10 +0,0 @@ -export default function openWindow(position) { - window.postMessage( - { - source: '@devtools-page', - type: 'OPEN', - position: position || 'right', - }, - '*' - ); -} diff --git a/extension/src/app/api/openWindow.ts b/extension/src/app/api/openWindow.ts new file mode 100644 index 00000000..51f9d145 --- /dev/null +++ b/extension/src/app/api/openWindow.ts @@ -0,0 +1,18 @@ +import { Action } from 'redux'; +import { PageScriptToContentScriptMessage } from './index'; + +export type Position = 'left' | 'right' | 'bottom' | 'panel' | 'remote'; + +function post>( + message: PageScriptToContentScriptMessage +) { + window.postMessage(message, '*'); +} + +export default function openWindow(position?: Position) { + post({ + source: '@devtools-page', + type: 'OPEN', + position: position || 'right', + }); +} diff --git a/extension/src/app/containers/App.js b/extension/src/app/containers/App.tsx similarity index 72% rename from extension/src/app/containers/App.js rename to extension/src/app/containers/App.tsx index df23bcee..85d13db0 100644 --- a/extension/src/app/containers/App.js +++ b/extension/src/app/containers/App.tsx @@ -1,19 +1,33 @@ import React, { Component } from 'react'; -import { connect } from 'react-redux'; +import { connect, ResolveThunks } from 'react-redux'; import { Container, Notification } from 'devui'; import { getActiveInstance } from '@redux-devtools/app/lib/reducers/instances'; import Settings from '@redux-devtools/app/lib/components/Settings'; import Actions from '@redux-devtools/app/lib/containers/Actions'; import Header from '@redux-devtools/app/lib/components/Header'; import { clearNotification } from '@redux-devtools/app/lib/actions'; +import { StoreState } from '@redux-devtools/app/lib/reducers'; +import { SingleMessage } from '../middlewares/api'; +import { Position } from '../api/openWindow'; -class App extends Component { - openWindow = (position) => { - chrome.runtime.sendMessage({ type: 'OPEN', position }); +type StateProps = ReturnType; +type DispatchProps = ResolveThunks; +interface OwnProps { + readonly position: string; +} +type Props = StateProps & DispatchProps & OwnProps; + +function sendMessage(message: SingleMessage) { + chrome.runtime.sendMessage(message); +} + +class App extends Component { + openWindow = (position: Position) => { + sendMessage({ type: 'OPEN', position }); }; openOptionsPage = () => { if (navigator.userAgent.indexOf('Firefox') !== -1) { - chrome.runtime.sendMessage({ type: 'OPEN_OPTIONS' }); + sendMessage({ type: 'OPEN_OPTIONS' }); } else { chrome.runtime.openOptionsPage(); } @@ -62,7 +76,7 @@ class App extends Component { } } -function mapStateToProps(state) { +function mapStateToProps(state: StoreState) { const instances = state.instances; const id = getActiveInstance(instances); return { diff --git a/extension/src/app/middlewares/api.js b/extension/src/app/middlewares/api.js deleted file mode 100644 index 785056d4..00000000 --- a/extension/src/app/middlewares/api.js +++ /dev/null @@ -1,264 +0,0 @@ -import stringifyJSON from '@redux-devtools/app/lib/utils/stringifyJSON'; -import { - UPDATE_STATE, - REMOVE_INSTANCE, - LIFTED_ACTION, -} from '@redux-devtools/app/lib/constants/actionTypes'; -import { nonReduxDispatch } from '@redux-devtools/app/lib/utils/monitorActions'; -import syncOptions from '../../browser/extension/options/syncOptions'; -import openDevToolsWindow from '../../browser/extension/background/openWindow'; -import { getReport } from '../../browser/extension/background/logging'; - -const CONNECTED = 'socket/CONNECTED'; -const DISCONNECTED = 'socket/DISCONNECTED'; -const connections = { - tab: {}, - panel: {}, - monitor: {}, -}; -const chunks = {}; -let monitors = 0; -let isMonitored = false; - -const getId = (sender, name) => - sender.tab ? sender.tab.id : name || sender.id; - -function toMonitors(action, tabId, verbose) { - Object.keys(connections.monitor).forEach((id) => { - connections.monitor[id].postMessage( - verbose || action.type === 'ERROR' ? action : { type: UPDATE_STATE } - ); - }); - Object.keys(connections.panel).forEach((id) => { - connections.panel[id].postMessage(action); - }); -} - -function toContentScript({ message, action, id, instanceId, state }) { - connections.tab[id].postMessage({ - type: message, - action, - state: nonReduxDispatch(window.store, message, instanceId, action, state), - id: instanceId.toString().replace(/^[^\/]+\//, ''), - }); -} - -function toAllTabs(msg) { - const tabs = connections.tab; - Object.keys(tabs).forEach((id) => { - tabs[id].postMessage(msg); - }); -} - -function monitorInstances(shouldMonitor, id) { - if (!id && isMonitored === shouldMonitor) return; - const action = { type: shouldMonitor ? 'START' : 'STOP' }; - if (id) { - if (connections.tab[id]) connections.tab[id].postMessage(action); - } else { - toAllTabs(action); - } - isMonitored = shouldMonitor; -} - -function getReducerError() { - const instancesState = window.store.getState().instances; - const payload = instancesState.states[instancesState.current]; - const computedState = payload.computedStates[payload.currentStateIndex]; - if (!computedState) return false; - return computedState.error; -} - -function togglePersist() { - const state = window.store.getState(); - if (state.persistStates) { - Object.keys(state.instances.connections).forEach((id) => { - if (connections.tab[id]) return; - window.store.dispatch({ type: REMOVE_INSTANCE, id }); - toMonitors({ type: 'NA', id }); - }); - } -} - -// Receive messages from content scripts -function messaging(request, sender, sendResponse) { - let tabId = getId(sender); - if (!tabId) return; - if (sender.frameId) tabId = `${tabId}-${sender.frameId}`; - - if (request.type === 'STOP') { - if (!Object.keys(window.store.getState().instances.connections).length) { - window.store.dispatch({ type: DISCONNECTED }); - } - return; - } - if (request.type === 'OPEN_OPTIONS') { - chrome.runtime.openOptionsPage(); - return; - } - if (request.type === 'GET_OPTIONS') { - window.syncOptions.get((options) => { - sendResponse({ options }); - }); - return; - } - if (request.type === 'GET_REPORT') { - getReport(request.payload, tabId, request.instanceId); - return; - } - if (request.type === 'OPEN') { - let position = 'devtools-left'; - if ( - ['remote', 'panel', 'left', 'right', 'bottom'].indexOf( - request.position - ) !== -1 - ) { - position = 'devtools-' + request.position; - } - openDevToolsWindow(position); - return; - } - if (request.type === 'ERROR') { - if (request.payload) { - toMonitors(request, tabId); - return; - } - if (!request.message) return; - const reducerError = getReducerError(); - chrome.notifications.create('app-error', { - type: 'basic', - title: reducerError - ? 'An error occurred in the reducer' - : 'An error occurred in the app', - message: reducerError || request.message, - iconUrl: 'img/logo/48x48.png', - isClickable: !!reducerError, - }); - return; - } - - const action = { type: UPDATE_STATE, request, id: tabId }; - const instanceId = `${tabId}/${request.instanceId}`; - if (request.split) { - if (request.split === 'start') { - chunks[instanceId] = request; - return; - } - if (request.split === 'chunk') { - chunks[instanceId][request.chunk[0]] = - (chunks[instanceId][request.chunk[0]] || '') + request.chunk[1]; - return; - } - action.request = chunks[instanceId]; - delete chunks[instanceId]; - } - if (request.instanceId) { - action.request.instanceId = instanceId; - } - window.store.dispatch(action); - - if (request.type === 'EXPORT') { - toMonitors(action, tabId, true); - } else { - toMonitors(action, tabId); - } -} - -function disconnect(type, id, listener) { - return function disconnectListener() { - const p = connections[type][id]; - if (listener && p) p.onMessage.removeListener(listener); - if (p) p.onDisconnect.removeListener(disconnectListener); - delete connections[type][id]; - if (type === 'tab') { - if (!window.store.getState().persistStates) { - window.store.dispatch({ type: REMOVE_INSTANCE, id }); - toMonitors({ type: 'NA', id }); - } - } else { - monitors--; - if (!monitors) monitorInstances(false); - } - }; -} - -function onConnect(port) { - let id; - let listener; - - window.store.dispatch({ type: CONNECTED, port }); - - if (port.name === 'tab') { - id = getId(port.sender); - if (port.sender.frameId) id = `${id}-${port.sender.frameId}`; - connections.tab[id] = port; - listener = (msg) => { - if (msg.name === 'INIT_INSTANCE') { - if (typeof id === 'number') { - chrome.pageAction.show(id); - chrome.pageAction.setIcon({ tabId: id, path: 'img/logo/38x38.png' }); - } - if (isMonitored) port.postMessage({ type: 'START' }); - - const state = window.store.getState(); - if (state.persistStates) { - const instanceId = `${id}/${msg.instanceId}`; - const persistedState = state.instances.states[instanceId]; - if (!persistedState) return; - toContentScript({ - message: 'IMPORT', - id, - instanceId, - state: stringifyJSON( - persistedState, - state.instances.options[instanceId].serialize - ), - }); - } - return; - } - if (msg.name === 'RELAY') { - messaging(msg.message, port.sender, id); - } - }; - port.onMessage.addListener(listener); - port.onDisconnect.addListener(disconnect('tab', id, listener)); - } else if (port.name && port.name.indexOf('monitor') === 0) { - id = getId(port.sender, port.name); - connections.monitor[id] = port; - monitorInstances(true); - monitors++; - port.onDisconnect.addListener(disconnect('monitor', id)); - } else { - // devpanel - id = port.name || port.sender.frameId; - connections.panel[id] = port; - monitorInstances(true, port.name); - monitors++; - listener = (msg) => { - window.store.dispatch(msg); - }; - port.onMessage.addListener(listener); - port.onDisconnect.addListener(disconnect('panel', id, listener)); - } -} - -chrome.runtime.onConnect.addListener(onConnect); -chrome.runtime.onConnectExternal.addListener(onConnect); -chrome.runtime.onMessage.addListener(messaging); -chrome.runtime.onMessageExternal.addListener(messaging); - -chrome.notifications.onClicked.addListener((id) => { - chrome.notifications.clear(id); - openDevToolsWindow('devtools-right'); -}); - -window.syncOptions = syncOptions(toAllTabs); // Expose to the options page - -export default function api() { - return (next) => (action) => { - if (action.type === LIFTED_ACTION) toContentScript(action); - else if (action.type === 'TOGGLE_PERSIST') togglePersist(); - return next(action); - }; -} diff --git a/extension/src/app/middlewares/api.ts b/extension/src/app/middlewares/api.ts new file mode 100644 index 00000000..b6dd67c0 --- /dev/null +++ b/extension/src/app/middlewares/api.ts @@ -0,0 +1,591 @@ +import stringifyJSON from '@redux-devtools/app/lib/utils/stringifyJSON'; +import { + UPDATE_STATE, + REMOVE_INSTANCE, + LIFTED_ACTION, +} from '@redux-devtools/app/lib/constants/actionTypes'; +import { nonReduxDispatch } from '@redux-devtools/app/lib/utils/monitorActions'; +import syncOptions, { + Options, + OptionsMessage, + SyncOptions, +} from '../../browser/extension/options/syncOptions'; +import openDevToolsWindow, { + DevToolsPosition, +} from '../../browser/extension/background/openWindow'; +import { getReport } from '../../browser/extension/background/logging'; +import { + CustomAction, + DispatchAction as AppDispatchAction, + LibConfig, +} from '@redux-devtools/app/lib/actions'; +import { Action, Dispatch } from 'redux'; +import { + ContentScriptToBackgroundMessage, + SplitMessage, +} from '../../browser/extension/inject/contentScript'; +import { + ErrorMessage, + PageScriptToContentScriptMessageForwardedToMonitors, + PageScriptToContentScriptMessageWithoutDisconnectOrInitInstance, +} from '../api'; +import { LiftedState } from '@redux-devtools/instrument'; +import { + BackgroundAction, + LiftedActionAction, +} from '../stores/backgroundStore'; +import { Position } from '../api/openWindow'; + +interface TabMessageBase { + readonly type: string; + readonly state?: string | undefined; + readonly id?: string; +} + +interface StartAction extends TabMessageBase { + readonly type: 'START'; + readonly state?: never; + readonly id?: never; +} + +interface StopAction extends TabMessageBase { + readonly type: 'STOP'; + readonly state?: never; + readonly id?: never; +} + +interface DispatchAction extends TabMessageBase { + readonly type: 'DISPATCH'; + readonly action: AppDispatchAction; + readonly state: string | undefined; + readonly id: string; +} + +interface ImportAction extends TabMessageBase { + readonly type: 'IMPORT'; + readonly action: undefined; + readonly state: string | undefined; + readonly id: string; +} + +interface ActionAction extends TabMessageBase { + readonly type: 'ACTION'; + readonly action: string | CustomAction; + readonly state: string | undefined; + readonly id: string; +} + +interface ExportAction extends TabMessageBase { + readonly type: 'EXPORT'; + readonly action: undefined; + readonly state: string | undefined; + readonly id: string; +} + +export interface NAAction { + readonly type: 'NA'; + readonly id: string | number; +} + +interface InitMessage> { + readonly type: 'INIT'; + readonly payload: string; + instanceId: string; + readonly source: '@devtools-page'; + action?: string; + name?: string | undefined; + liftedState?: LiftedState; + libConfig?: LibConfig; +} + +interface LiftedMessage { + type: 'LIFTED'; + liftedState: { isPaused: boolean | undefined }; + instanceId: number; + source: '@devtools-page'; +} + +interface SerializedPartialLiftedState { + readonly stagedActionIds: readonly number[]; + readonly currentStateIndex: number; + readonly nextActionId: number; +} + +interface SerializedPartialStateMessage { + readonly type: 'PARTIAL_STATE'; + readonly payload: SerializedPartialLiftedState; + readonly source: '@devtools-page'; + instanceId: number; + readonly maxAge: number; + readonly actionsById: string; + readonly computedStates: string; + readonly committedState: boolean; +} + +interface SerializedExportMessage { + readonly type: 'EXPORT'; + readonly payload: string; + readonly committedState: string | undefined; + readonly source: '@devtools-page'; + instanceId: number; +} + +interface SerializedActionMessage { + readonly type: 'ACTION'; + readonly payload: string; + readonly source: '@devtools-page'; + instanceId: number; + readonly action: string; + readonly maxAge: number; + readonly nextActionId: number; +} + +interface SerializedStateMessage> { + readonly type: 'STATE'; + readonly payload: Omit< + LiftedState, + 'actionsById' | 'computedStates' | 'committedState' + >; + readonly source: '@devtools-page'; + instanceId: string; + readonly libConfig?: LibConfig; + readonly actionsById: string; + readonly computedStates: string; + readonly committedState: boolean; +} + +type UpdateStateRequest> = + | InitMessage + | LiftedMessage + | SerializedPartialStateMessage + | SerializedExportMessage + | SerializedActionMessage + | SerializedStateMessage; + +export interface EmptyUpdateStateAction { + readonly type: typeof UPDATE_STATE; +} + +interface UpdateStateAction> { + readonly type: typeof UPDATE_STATE; + request: UpdateStateRequest; + readonly id: string | number; +} + +export type TabMessage = + | StartAction + | StopAction + | OptionsMessage + | DispatchAction + | ImportAction + | ActionAction + | ExportAction; +export type PanelMessage> = + | NAAction + | ErrorMessage + | UpdateStateAction; +export type MonitorMessage = NAAction | ErrorMessage | EmptyUpdateStateAction; + +type TabPort = Omit & { + postMessage: (message: TabMessage) => void; +}; +type PanelPort = Omit & { + postMessage: >( + message: PanelMessage + ) => void; +}; +type MonitorPort = Omit & { + postMessage: (message: MonitorMessage) => void; +}; + +export const CONNECTED = 'socket/CONNECTED'; +export const DISCONNECTED = 'socket/DISCONNECTED'; +const connections: { + readonly tab: { [K in number | string]: TabPort }; + readonly panel: { [K in number | string]: PanelPort }; + readonly monitor: { [K in number | string]: MonitorPort }; +} = { + tab: {}, + panel: {}, + monitor: {}, +}; +const chunks: { + [instanceId: string]: PageScriptToContentScriptMessageForwardedToMonitors< + unknown, + Action + >; +} = {}; +let monitors = 0; +let isMonitored = false; + +const getId = (sender: chrome.runtime.MessageSender, name?: string) => + sender.tab ? sender.tab.id! : name || sender.id!; + +type MonitorAction> = + | NAAction + | ErrorMessage + | UpdateStateAction; + +function toMonitors>( + action: MonitorAction, + tabId?: string | number, + verbose?: boolean +) { + Object.keys(connections.monitor).forEach((id) => { + connections.monitor[id].postMessage( + verbose || action.type === 'ERROR' ? action : { type: UPDATE_STATE } + ); + }); + Object.keys(connections.panel).forEach((id) => { + connections.panel[id].postMessage(action); + }); +} + +interface ImportMessage { + readonly message: 'IMPORT'; + readonly id: string | number; + readonly instanceId: string; + readonly state: string; + readonly action?: never; +} + +type ToContentScriptMessage = ImportMessage | LiftedActionAction; + +function toContentScript(messageBody: ToContentScriptMessage) { + if (messageBody.message === 'DISPATCH') { + const { message, action, id, instanceId, state } = messageBody; + connections.tab[id!].postMessage({ + type: message, + action, + state: nonReduxDispatch( + window.store, + message, + instanceId, + action as AppDispatchAction, + state + ), + id: instanceId.toString().replace(/^[^\/]+\//, ''), + }); + } else if (messageBody.message === 'IMPORT') { + const { message, action, id, instanceId, state } = messageBody; + connections.tab[id!].postMessage({ + type: message, + action, + state: nonReduxDispatch( + window.store, + message, + instanceId, + action as unknown as AppDispatchAction, + state + ), + id: instanceId.toString().replace(/^[^\/]+\//, ''), + }); + } else if (messageBody.message === 'ACTION') { + const { message, action, id, instanceId, state } = messageBody; + connections.tab[id!].postMessage({ + type: message, + action, + state: nonReduxDispatch( + window.store, + message, + instanceId, + action as unknown as AppDispatchAction, + state + ), + id: instanceId.toString().replace(/^[^\/]+\//, ''), + }); + } else if (messageBody.message === 'EXPORT') { + const { message, action, id, instanceId, state } = messageBody; + connections.tab[id!].postMessage({ + type: message, + action, + state: nonReduxDispatch( + window.store, + message, + instanceId, + action as unknown as AppDispatchAction, + state + ), + id: instanceId.toString().replace(/^[^\/]+\//, ''), + }); + } else { + const { message, action, id, instanceId, state } = messageBody; + connections.tab[id!].postMessage({ + type: message, + action, + state: nonReduxDispatch( + window.store, + message, + instanceId, + action as AppDispatchAction, + state + ), + id: (instanceId as number).toString().replace(/^[^\/]+\//, ''), + }); + } +} + +function toAllTabs(msg: TabMessage) { + const tabs = connections.tab; + Object.keys(tabs).forEach((id) => { + tabs[id].postMessage(msg); + }); +} + +function monitorInstances(shouldMonitor: boolean, id?: string) { + if (!id && isMonitored === shouldMonitor) return; + const action = { + type: shouldMonitor ? ('START' as const) : ('STOP' as const), + }; + if (id) { + if (connections.tab[id]) connections.tab[id].postMessage(action); + } else { + toAllTabs(action); + } + isMonitored = shouldMonitor; +} + +function getReducerError() { + const instancesState = window.store.getState().instances; + const payload = instancesState.states[instancesState.current]; + const computedState = payload.computedStates[payload.currentStateIndex]; + if (!computedState) return false; + return computedState.error; +} + +function togglePersist() { + const state = window.store.getState(); + if (state.persistStates) { + Object.keys(state.instances.connections).forEach((id) => { + if (connections.tab[id]) return; + window.store.dispatch({ type: REMOVE_INSTANCE, id }); + toMonitors({ type: 'NA', id }); + }); + } +} + +interface OpenMessage { + readonly type: 'OPEN'; + readonly position: Position; +} + +interface OpenOptionsMessage { + readonly type: 'OPEN_OPTIONS'; +} + +interface GetOptionsMessage { + readonly type: 'GET_OPTIONS'; +} + +export type SingleMessage = + | OpenMessage + | OpenOptionsMessage + | GetOptionsMessage; + +type BackgroundStoreMessage> = + | PageScriptToContentScriptMessageWithoutDisconnectOrInitInstance + | SplitMessage + | SingleMessage; +type BackgroundStoreResponse = { readonly options: Options }; + +// Receive messages from content scripts +function messaging>( + request: BackgroundStoreMessage, + sender: chrome.runtime.MessageSender, + sendResponse?: (response?: BackgroundStoreResponse) => void +) { + let tabId = getId(sender); + if (!tabId) return; + if (sender.frameId) tabId = `${tabId}-${sender.frameId}`; + + if (request.type === 'STOP') { + if (!Object.keys(window.store.getState().instances.connections).length) { + window.store.dispatch({ type: DISCONNECTED }); + } + return; + } + if (request.type === 'OPEN_OPTIONS') { + chrome.runtime.openOptionsPage(); + return; + } + if (request.type === 'GET_OPTIONS') { + window.syncOptions.get((options) => { + sendResponse!({ options }); + }); + return; + } + if (request.type === 'GET_REPORT') { + getReport(request.payload, tabId, request.instanceId); + return; + } + if (request.type === 'OPEN') { + let position: DevToolsPosition = 'devtools-left'; + if ( + ['remote', 'panel', 'left', 'right', 'bottom'].indexOf( + request.position + ) !== -1 + ) { + position = ('devtools-' + request.position) as DevToolsPosition; + } + openDevToolsWindow(position); + return; + } + if (request.type === 'ERROR') { + if (request.payload) { + toMonitors(request, tabId); + return; + } + if (!request.message) return; + const reducerError = getReducerError(); + chrome.notifications.create('app-error', { + type: 'basic', + title: reducerError + ? 'An error occurred in the reducer' + : 'An error occurred in the app', + message: reducerError || request.message, + iconUrl: 'img/logo/48x48.png', + isClickable: !!reducerError, + }); + return; + } + + const action: UpdateStateAction = { + type: UPDATE_STATE, + request, + id: tabId, + } as UpdateStateAction; + const instanceId = `${tabId}/${request.instanceId}`; + if ('split' in request) { + if (request.split === 'start') { + chunks[instanceId] = request as any; + return; + } + if (request.split === 'chunk') { + (chunks[instanceId] as any)[request.chunk[0]] = + ((chunks[instanceId] as any)[request.chunk[0]] || '') + + request.chunk[1]; + return; + } + action.request = chunks[instanceId] as any; + delete chunks[instanceId]; + } + if (request.instanceId) { + action.request.instanceId = instanceId; + } + window.store.dispatch(action); + + if (request.type === 'EXPORT') { + toMonitors(action, tabId, true); + } else { + toMonitors(action, tabId); + } +} + +function disconnect( + type: 'tab' | 'monitor' | 'panel', + id: number | string, + listener?: (message: any, port: chrome.runtime.Port) => void +) { + return function disconnectListener() { + const p = connections[type][id]; + if (listener && p) p.onMessage.removeListener(listener); + if (p) p.onDisconnect.removeListener(disconnectListener); + delete connections[type][id]; + if (type === 'tab') { + if (!window.store.getState().persistStates) { + window.store.dispatch({ type: REMOVE_INSTANCE, id }); + toMonitors({ type: 'NA', id }); + } + } else { + monitors--; + if (!monitors) monitorInstances(false); + } + }; +} + +function onConnect>(port: chrome.runtime.Port) { + let id: number | string; + let listener; + + window.store.dispatch({ type: CONNECTED, port }); + + if (port.name === 'tab') { + id = getId(port.sender!); + if (port.sender!.frameId) id = `${id}-${port.sender!.frameId}`; + connections.tab[id] = port; + listener = (msg: ContentScriptToBackgroundMessage) => { + if (msg.name === 'INIT_INSTANCE') { + if (typeof id === 'number') { + chrome.pageAction.show(id); + chrome.pageAction.setIcon({ tabId: id, path: 'img/logo/38x38.png' }); + } + if (isMonitored) port.postMessage({ type: 'START' }); + + const state = window.store.getState(); + if (state.persistStates) { + const instanceId = `${id}/${msg.instanceId}`; + const persistedState = state.instances.states[instanceId]; + if (!persistedState) return; + toContentScript({ + message: 'IMPORT', + id, + instanceId, + state: stringifyJSON( + persistedState, + state.instances.options[instanceId].serialize + ), + }); + } + return; + } + if (msg.name === 'RELAY') { + messaging(msg.message, port.sender!); + } + }; + port.onMessage.addListener(listener); + port.onDisconnect.addListener(disconnect('tab', id, listener)); + } else if (port.name && port.name.indexOf('monitor') === 0) { + id = getId(port.sender!, port.name); + connections.monitor[id] = port; + monitorInstances(true); + monitors++; + port.onDisconnect.addListener(disconnect('monitor', id)); + } else { + // devpanel + id = port.name || port.sender!.frameId!; + connections.panel[id] = port; + monitorInstances(true, port.name); + monitors++; + listener = (msg: BackgroundAction) => { + window.store.dispatch(msg); + }; + port.onMessage.addListener(listener); + port.onDisconnect.addListener(disconnect('panel', id, listener)); + } +} + +chrome.runtime.onConnect.addListener(onConnect); +chrome.runtime.onConnectExternal.addListener(onConnect); +chrome.runtime.onMessage.addListener(messaging); +chrome.runtime.onMessageExternal.addListener(messaging); + +chrome.notifications.onClicked.addListener((id) => { + chrome.notifications.clear(id); + openDevToolsWindow('devtools-right'); +}); + +declare global { + interface Window { + syncOptions: SyncOptions; + } +} + +window.syncOptions = syncOptions(toAllTabs); // Expose to the options page + +export default function api() { + return (next: Dispatch) => (action: BackgroundAction) => { + if (action.type === LIFTED_ACTION) toContentScript(action); + else if (action.type === 'TOGGLE_PERSIST') togglePersist(); + return next(action); + }; +} diff --git a/extension/src/app/middlewares/instanceSelector.js b/extension/src/app/middlewares/instanceSelector.ts similarity index 60% rename from extension/src/app/middlewares/instanceSelector.js rename to extension/src/app/middlewares/instanceSelector.ts index 72c6d03d..fd87bd93 100644 --- a/extension/src/app/middlewares/instanceSelector.js +++ b/extension/src/app/middlewares/instanceSelector.ts @@ -1,9 +1,16 @@ +import { Dispatch, MiddlewareAPI } from 'redux'; import { SELECT_INSTANCE, UPDATE_STATE, } from '@redux-devtools/app/lib/constants/actionTypes'; +import { StoreAction } from '@redux-devtools/app/lib/actions'; +import { StoreState } from '@redux-devtools/app/lib/reducers'; -function selectInstance(tabId, store, next) { +function selectInstance( + tabId: number, + store: MiddlewareAPI, StoreState>, + next: Dispatch +) { const instances = store.getState().instances; if (instances.current === 'default') return; const connections = instances.connections[tabId]; @@ -12,7 +19,7 @@ function selectInstance(tabId, store, next) { } } -function getCurrentTabId(next) { +function getCurrentTabId(next: (tabId: number) => void) { chrome.tabs.query( { active: true, @@ -21,13 +28,15 @@ function getCurrentTabId(next) { (tabs) => { const tab = tabs[0]; if (!tab) return; - next(tab.id); + next(tab.id!); } ); } -export default function popupSelector(store) { - return (next) => (action) => { +export default function popupSelector( + store: MiddlewareAPI, StoreState> +) { + return (next: Dispatch) => (action: StoreAction) => { const result = next(action); if (action.type === UPDATE_STATE) { if (chrome.devtools && chrome.devtools.inspectedWindow) { diff --git a/extension/src/app/middlewares/panelSync.js b/extension/src/app/middlewares/panelSync.js deleted file mode 100644 index f88f421d..00000000 --- a/extension/src/app/middlewares/panelSync.js +++ /dev/null @@ -1,31 +0,0 @@ -import { - LIFTED_ACTION, - UPDATE_STATE, - SELECT_INSTANCE, -} from '@redux-devtools/app/lib/constants/actionTypes'; -import { getActiveInstance } from '@redux-devtools/app/lib/reducers/instances'; - -function panelDispatcher(bgConnection) { - let autoselected = false; - const tabId = chrome.devtools.inspectedWindow.tabId; - - return (store) => (next) => (action) => { - const result = next(action); - if (!autoselected && action.type === UPDATE_STATE && tabId) { - autoselected = true; - const connections = store.getState().instances.connections[tabId]; - if (connections && connections.length === 1) { - next({ type: SELECT_INSTANCE, selected: connections[0] }); - } - } - if (action.type === LIFTED_ACTION || action.type === 'TOGGLE_PERSIST') { - const instances = store.getState().instances; - const instanceId = getActiveInstance(instances); - const id = instances.options[instanceId].connectionId; - bgConnection.postMessage({ ...action, instanceId, id }); - } - return result; - }; -} - -export default panelDispatcher; diff --git a/extension/src/app/middlewares/panelSync.ts b/extension/src/app/middlewares/panelSync.ts new file mode 100644 index 00000000..488708b3 --- /dev/null +++ b/extension/src/app/middlewares/panelSync.ts @@ -0,0 +1,38 @@ +import { + LIFTED_ACTION, + UPDATE_STATE, + SELECT_INSTANCE, +} from '@redux-devtools/app/lib/constants/actionTypes'; +import { getActiveInstance } from '@redux-devtools/app/lib/reducers/instances'; +import { Dispatch, MiddlewareAPI } from 'redux'; +import { StoreState } from '@redux-devtools/app/lib/reducers'; +import { StoreActionWithTogglePersist } from '../stores/windowStore'; + +function panelDispatcher(bgConnection: chrome.runtime.Port) { + let autoselected = false; + const tabId = chrome.devtools.inspectedWindow.tabId; + + return ( + store: MiddlewareAPI, StoreState> + ) => + (next: Dispatch) => + (action: StoreActionWithTogglePersist) => { + const result = next(action); + if (!autoselected && action.type === UPDATE_STATE && tabId) { + autoselected = true; + const connections = store.getState().instances.connections[tabId]; + if (connections && connections.length === 1) { + next({ type: SELECT_INSTANCE, selected: connections[0] }); + } + } + if (action.type === LIFTED_ACTION || action.type === 'TOGGLE_PERSIST') { + const instances = store.getState().instances; + const instanceId = getActiveInstance(instances); + const id = instances.options[instanceId].connectionId; + bgConnection.postMessage({ ...action, instanceId, id }); + } + return result; + }; +} + +export default panelDispatcher; diff --git a/extension/src/app/middlewares/windowSync.js b/extension/src/app/middlewares/windowSync.js deleted file mode 100644 index 442d3162..00000000 --- a/extension/src/app/middlewares/windowSync.js +++ /dev/null @@ -1,23 +0,0 @@ -import { - UPDATE_STATE, - LIFTED_ACTION, -} from '@redux-devtools/app/lib/constants/actionTypes'; -import { getActiveInstance } from '@redux-devtools/app/lib/reducers/instances'; - -const syncStores = (baseStore) => (store) => (next) => (action) => { - if (action.type === UPDATE_STATE) { - return next({ - ...action, - instances: baseStore.getState().instances, - }); - } - if (action.type === LIFTED_ACTION || action.type === 'TOGGLE_PERSIST') { - const instances = store.getState().instances; - const instanceId = getActiveInstance(instances); - const id = instances.options[instanceId].connectionId; - baseStore.dispatch({ ...action, instanceId, id }); - } - return next(action); -}; - -export default syncStores; diff --git a/extension/src/app/middlewares/windowSync.ts b/extension/src/app/middlewares/windowSync.ts new file mode 100644 index 00000000..f8823ba8 --- /dev/null +++ b/extension/src/app/middlewares/windowSync.ts @@ -0,0 +1,36 @@ +import { + UPDATE_STATE, + LIFTED_ACTION, +} from '@redux-devtools/app/lib/constants/actionTypes'; +import { getActiveInstance } from '@redux-devtools/app/lib/reducers/instances'; +import { Dispatch, MiddlewareAPI, Store } from 'redux'; +import { BackgroundState } from '../reducers/background'; +import { StoreAction } from '@redux-devtools/app/lib/actions'; +import { + WindowStoreAction, + StoreActionWithTogglePersist, +} from '../stores/windowStore'; +import { StoreState } from '@redux-devtools/app/lib/reducers'; +import { BackgroundAction } from '../stores/backgroundStore'; + +const syncStores = + (baseStore: Store) => + (store: MiddlewareAPI, StoreState>) => + (next: Dispatch) => + (action: StoreActionWithTogglePersist) => { + if (action.type === UPDATE_STATE) { + return next({ + ...action, + instances: baseStore.getState().instances, + }); + } + if (action.type === LIFTED_ACTION || action.type === 'TOGGLE_PERSIST') { + const instances = store.getState().instances; + const instanceId = getActiveInstance(instances); + const id = instances.options[instanceId].connectionId; + baseStore.dispatch({ ...action, instanceId, id } as any); + } + return next(action); + }; + +export default syncStores; diff --git a/extension/src/app/reducers/background/index.js b/extension/src/app/reducers/background/index.js deleted file mode 100644 index 81bd41c6..00000000 --- a/extension/src/app/reducers/background/index.js +++ /dev/null @@ -1,10 +0,0 @@ -import { combineReducers } from 'redux'; -import instances from '@redux-devtools/app/lib/reducers/instances'; -import persistStates from './persistStates'; - -const rootReducer = combineReducers({ - instances, - persistStates, -}); - -export default rootReducer; diff --git a/extension/src/app/reducers/background/index.ts b/extension/src/app/reducers/background/index.ts new file mode 100644 index 00000000..d2d607ed --- /dev/null +++ b/extension/src/app/reducers/background/index.ts @@ -0,0 +1,19 @@ +import { combineReducers, Reducer } from 'redux'; +import instances, { + InstancesState, +} from '@redux-devtools/app/lib/reducers/instances'; +import persistStates from './persistStates'; +import { BackgroundAction } from '../../stores/backgroundStore'; + +export interface BackgroundState { + readonly instances: InstancesState; + readonly persistStates: boolean; +} + +const rootReducer: Reducer = + combineReducers({ + instances, + persistStates, + }); + +export default rootReducer; diff --git a/extension/src/app/reducers/background/persistStates.js b/extension/src/app/reducers/background/persistStates.js deleted file mode 100644 index 0505da33..00000000 --- a/extension/src/app/reducers/background/persistStates.js +++ /dev/null @@ -1,4 +0,0 @@ -export default function persistStates(state = false, action) { - if (action.type === 'TOGGLE_PERSIST') return !state; - return state; -} diff --git a/extension/src/app/reducers/background/persistStates.ts b/extension/src/app/reducers/background/persistStates.ts new file mode 100644 index 00000000..90b5c5c7 --- /dev/null +++ b/extension/src/app/reducers/background/persistStates.ts @@ -0,0 +1,6 @@ +import { BackgroundAction } from '../../stores/backgroundStore'; + +export default function persistStates(state = false, action: BackgroundAction) { + if (action.type === 'TOGGLE_PERSIST') return !state; + return state; +} diff --git a/extension/src/app/reducers/panel/index.js b/extension/src/app/reducers/panel/index.js deleted file mode 100644 index 42cc531f..00000000 --- a/extension/src/app/reducers/panel/index.js +++ /dev/null @@ -1,18 +0,0 @@ -import { combineReducers } from 'redux'; -import instances from '@redux-devtools/app/lib/reducers/instances'; -import monitor from '@redux-devtools/app/lib/reducers/monitor'; -import notification from '@redux-devtools/app/lib/reducers/notification'; -import reports from '@redux-devtools/app/lib/reducers/reports'; -import section from '@redux-devtools/app/lib/reducers/section'; -import theme from '@redux-devtools/app/lib/reducers/theme'; - -const rootReducer = combineReducers({ - instances, - monitor, - reports, - notification, - section, - theme, -}); - -export default rootReducer; diff --git a/extension/src/app/reducers/panel/index.ts b/extension/src/app/reducers/panel/index.ts new file mode 100644 index 00000000..a463a24e --- /dev/null +++ b/extension/src/app/reducers/panel/index.ts @@ -0,0 +1,25 @@ +import { combineReducers, Reducer } from 'redux'; +import instances from '@redux-devtools/app/lib/reducers/instances'; +import monitor from '@redux-devtools/app/lib/reducers/monitor'; +import notification from '@redux-devtools/app/lib/reducers/notification'; +import reports from '@redux-devtools/app/lib/reducers/reports'; +import section from '@redux-devtools/app/lib/reducers/section'; +import theme from '@redux-devtools/app/lib/reducers/theme'; +import connection from '@redux-devtools/app/lib/reducers/connection'; +import socket from '@redux-devtools/app/lib/reducers/socket'; +import { StoreState } from '@redux-devtools/app/lib/reducers'; +import { StoreActionWithTogglePersist } from '../../stores/windowStore'; + +const rootReducer: Reducer = + combineReducers({ + instances, + monitor, + reports, + notification, + section, + theme, + connection, + socket, + }); + +export default rootReducer; diff --git a/extension/src/app/reducers/window/index.js b/extension/src/app/reducers/window/index.ts similarity index 50% rename from extension/src/app/reducers/window/index.js rename to extension/src/app/reducers/window/index.ts index 5420e736..c4bf9329 100644 --- a/extension/src/app/reducers/window/index.js +++ b/extension/src/app/reducers/window/index.ts @@ -1,4 +1,4 @@ -import { combineReducers } from 'redux'; +import { combineReducers, Reducer } from 'redux'; import instances from './instances'; import monitor from '@redux-devtools/app/lib/reducers/monitor'; import notification from '@redux-devtools/app/lib/reducers/notification'; @@ -6,15 +6,20 @@ import socket from '@redux-devtools/app/lib/reducers/socket'; import reports from '@redux-devtools/app/lib/reducers/reports'; import section from '@redux-devtools/app/lib/reducers/section'; import theme from '@redux-devtools/app/lib/reducers/theme'; +import connection from '@redux-devtools/app/lib/reducers/connection'; +import { StoreState } from '@redux-devtools/app/lib/reducers'; +import { WindowStoreAction } from '../../stores/windowStore'; -const rootReducer = combineReducers({ - instances, - monitor, - socket, - reports, - notification, - section, - theme, -}); +const rootReducer: Reducer = + combineReducers({ + instances, + monitor, + socket, + reports, + notification, + section, + theme, + connection, + }); export default rootReducer; diff --git a/extension/src/app/reducers/window/instances.js b/extension/src/app/reducers/window/instances.ts similarity index 62% rename from extension/src/app/reducers/window/instances.js rename to extension/src/app/reducers/window/instances.ts index 7454eb90..1db1a6ec 100644 --- a/extension/src/app/reducers/window/instances.js +++ b/extension/src/app/reducers/window/instances.ts @@ -7,11 +7,21 @@ import { SELECT_INSTANCE, LIFTED_ACTION, } from '@redux-devtools/app/lib/constants/actionTypes'; +import { + ExpandedUpdateStateAction, + WindowStoreAction, +} from '../../stores/windowStore'; -export default function instances(state = initialState, action) { +export default function instances( + state = initialState, + action: WindowStoreAction +) { switch (action.type) { case UPDATE_STATE: - return { ...action.instances, selected: state.selected }; + return { + ...(action as ExpandedUpdateStateAction).instances, + selected: state.selected, + }; case LIFTED_ACTION: if (action.message === 'DISPATCH') return dispatchAction(state, action); return state; diff --git a/extension/src/app/service/Monitor.js b/extension/src/app/service/Monitor.ts similarity index 64% rename from extension/src/app/service/Monitor.js rename to extension/src/app/service/Monitor.ts index c3036e9f..a3d0dd33 100644 --- a/extension/src/app/service/Monitor.js +++ b/extension/src/app/service/Monitor.ts @@ -1,8 +1,32 @@ -export default class Monitor { - constructor(update) { +import { Action } from 'redux'; +import { LiftedState } from '@redux-devtools/instrument'; +import { DispatchAction, LibConfig } from '@redux-devtools/app/lib/actions'; + +declare global { + interface Window { + __REDUX_DEVTOOLS_EXTENSION_LOCKED__?: boolean; + } +} + +export default class Monitor> { + update: ( + liftedState?: LiftedState | undefined, + libConfig?: LibConfig + ) => void; + active?: boolean; + paused?: boolean; + lastAction?: string; + waitingTimeout?: number; + + constructor( + update: ( + liftedState?: LiftedState | undefined, + libConfig?: LibConfig + ) => void + ) { this.update = update; } - reducer = (state = {}, action) => { + reducer = (state = {}, action: DispatchAction) => { if (!this.active) return state; this.lastAction = action.type; if (action.type === 'LOCK_CHANGES') { @@ -15,7 +39,7 @@ export default class Monitor { } return state; }; - start = (skipUpdate) => { + start = (skipUpdate: boolean) => { this.active = true; if (!skipUpdate) this.update(); }; diff --git a/extension/src/app/stores/backgroundStore.js b/extension/src/app/stores/backgroundStore.js deleted file mode 100644 index 2a9568e2..00000000 --- a/extension/src/app/stores/backgroundStore.js +++ /dev/null @@ -1,18 +0,0 @@ -import { createStore, applyMiddleware } from 'redux'; -import rootReducer from '../reducers/background'; -import api from '../middlewares/api'; - -export default function configureStore(preloadedState) { - return createStore(rootReducer, preloadedState, applyMiddleware(api)); - /* - let enhancer; - if (process.env.NODE_ENV === 'production') { - enhancer = applyMiddleware(api); - } else { - const logger = require('redux-logger'); - enhancer = applyMiddleware(api, logger()); - } - - return createStore(rootReducer, preloadedState, enhancer); -*/ -} diff --git a/extension/src/app/stores/backgroundStore.ts b/extension/src/app/stores/backgroundStore.ts new file mode 100644 index 00000000..ace3e77e --- /dev/null +++ b/extension/src/app/stores/backgroundStore.ts @@ -0,0 +1,84 @@ +import { createStore, applyMiddleware, PreloadedState } from 'redux'; +import rootReducer, { BackgroundState } from '../reducers/background'; +import api, { CONNECTED, DISCONNECTED } from '../middlewares/api'; +import { LIFTED_ACTION } from '@redux-devtools/app/lib/constants/actionTypes'; +import { + CustomAction, + DispatchAction, + StoreActionWithoutLiftedAction, +} from '@redux-devtools/app/lib/actions'; + +interface LiftedActionActionBase { + action?: DispatchAction | string | CustomAction; + state?: string; + toAll?: boolean; + readonly instanceId: string | number; + readonly id: string | number | undefined; +} +interface LiftedActionDispatchAction extends LiftedActionActionBase { + type: typeof LIFTED_ACTION; + message: 'DISPATCH'; + action: DispatchAction; + toAll?: boolean; +} +interface LiftedActionImportAction extends LiftedActionActionBase { + type: typeof LIFTED_ACTION; + message: 'IMPORT'; + state: string; + preloadedState?: unknown | undefined; + action?: never; +} +interface LiftedActionActionAction extends LiftedActionActionBase { + type: typeof LIFTED_ACTION; + message: 'ACTION'; + action: string | CustomAction; +} +interface LiftedActionExportAction extends LiftedActionActionBase { + type: typeof LIFTED_ACTION; + message: 'EXPORT'; + toExport: boolean; + action?: never; +} +export type LiftedActionAction = + | LiftedActionDispatchAction + | LiftedActionImportAction + | LiftedActionActionAction + | LiftedActionExportAction; + +interface TogglePersistAction { + readonly type: 'TOGGLE_PERSIST'; + readonly instanceId: string | number; + readonly id: string | number | undefined; +} + +interface ConnectedAction { + readonly type: typeof CONNECTED; +} + +interface DisconnectedAction { + readonly type: typeof DISCONNECTED; +} + +export type BackgroundAction = + | StoreActionWithoutLiftedAction + | LiftedActionAction + | TogglePersistAction + | ConnectedAction + | DisconnectedAction; + +export default function configureStore( + preloadedState?: PreloadedState +) { + return createStore(rootReducer, preloadedState, applyMiddleware(api)); + /* + let enhancer; + if (process.env.NODE_ENV === 'production') { + enhancer = applyMiddleware(api); + } else { + const logger = require('redux-logger'); + enhancer = applyMiddleware(api, logger()); + } + + return createStore(rootReducer, preloadedState, enhancer); +*/ +} diff --git a/extension/src/app/stores/createStore.js b/extension/src/app/stores/createStore.js deleted file mode 100644 index ecd8492c..00000000 --- a/extension/src/app/stores/createStore.js +++ /dev/null @@ -1,5 +0,0 @@ -import { createStore } from 'redux'; - -export default function configureStore(reducer, initialState, enhance) { - return createStore(reducer, initialState, enhance()); -} diff --git a/extension/src/app/stores/createStore.ts b/extension/src/app/stores/createStore.ts new file mode 100644 index 00000000..f91dd548 --- /dev/null +++ b/extension/src/app/stores/createStore.ts @@ -0,0 +1,15 @@ +import { + Action, + createStore, + PreloadedState, + Reducer, + StoreEnhancer, +} from 'redux'; + +export default function configureStore>( + reducer: Reducer, + initialState: PreloadedState | undefined, + enhance: () => StoreEnhancer +) { + return createStore(reducer, initialState, enhance()); +} diff --git a/extension/src/app/stores/enhancerStore.js b/extension/src/app/stores/enhancerStore.ts similarity index 60% rename from extension/src/app/stores/enhancerStore.js rename to extension/src/app/stores/enhancerStore.ts index fdf2a850..75d83cd6 100644 --- a/extension/src/app/stores/enhancerStore.js +++ b/extension/src/app/stores/enhancerStore.ts @@ -1,15 +1,31 @@ -import { compose } from 'redux'; +import { Action, compose, Reducer, StoreEnhancerStoreCreator } from 'redux'; import instrument from '@redux-devtools/instrument'; import persistState from '@redux-devtools/core/lib/persistState'; +import { ConfigWithExpandedMaxAge } from '../../browser/extension/inject/pageScript'; -export function getUrlParam(key) { +export function getUrlParam(key: string) { const matches = window.location.href.match( new RegExp(`[?&]${key}=([^&#]+)\\b`) ); return matches && matches.length > 0 ? matches[1] : null; } -export default function configureStore(next, monitorReducer, config) { +declare global { + interface Window { + shouldCatchErrors?: boolean; + } +} + +export default function configureStore< + S, + A extends Action, + MonitorState, + MonitorAction extends Action +>( + next: StoreEnhancerStoreCreator, + monitorReducer: Reducer, + config: ConfigWithExpandedMaxAge +) { return compose( instrument(monitorReducer, { maxAge: config.maxAge, diff --git a/extension/src/app/stores/panelStore.js b/extension/src/app/stores/panelStore.ts similarity index 53% rename from extension/src/app/stores/panelStore.js rename to extension/src/app/stores/panelStore.ts index 2255bc22..977ed96e 100644 --- a/extension/src/app/stores/panelStore.js +++ b/extension/src/app/stores/panelStore.ts @@ -1,9 +1,14 @@ -import { createStore, applyMiddleware } from 'redux'; +import { createStore, applyMiddleware, PreloadedState } from 'redux'; import exportState from '@redux-devtools/app/lib/middlewares/exportState'; import panelDispatcher from '../middlewares/panelSync'; import rootReducer from '../reducers/panel'; +import { StoreState } from '@redux-devtools/app/lib/reducers'; -export default function configureStore(position, bgConnection, preloadedState) { +export default function configureStore( + position: string, + bgConnection: chrome.runtime.Port, + preloadedState: PreloadedState +) { const enhancer = applyMiddleware(exportState, panelDispatcher(bgConnection)); return createStore(rootReducer, preloadedState, enhancer); } diff --git a/extension/src/app/stores/windowStore.js b/extension/src/app/stores/windowStore.ts similarity index 51% rename from extension/src/app/stores/windowStore.js rename to extension/src/app/stores/windowStore.ts index 2e67ef96..fa1b406c 100644 --- a/extension/src/app/stores/windowStore.js +++ b/extension/src/app/stores/windowStore.ts @@ -1,13 +1,51 @@ -import { createStore, compose, applyMiddleware } from 'redux'; +import { + createStore, + compose, + applyMiddleware, + Store, + PreloadedState, + StoreEnhancer, +} from 'redux'; import exportState from '@redux-devtools/app/lib/middlewares/exportState'; import api from '@redux-devtools/app/lib/middlewares/api'; import { CONNECT_REQUEST } from '@redux-devtools/app/lib/constants/socketActionTypes'; +import { StoreState } from '@redux-devtools/app/lib/reducers'; +import { + StoreAction, + StoreActionWithoutUpdateState, + UpdateStateAction, +} from '@redux-devtools/app/lib/actions'; +import { InstancesState } from '@redux-devtools/app/lib/reducers/instances'; import syncStores from '../middlewares/windowSync'; import instanceSelector from '../middlewares/instanceSelector'; import rootReducer from '../reducers/window'; +import { BackgroundState } from '../reducers/background'; +import { BackgroundAction } from './backgroundStore'; +import { EmptyUpdateStateAction, NAAction } from '../middlewares/api'; -export default function configureStore(baseStore, position, preloadedState) { - let enhancer; +export interface TogglePersistAction { + readonly type: 'TOGGLE_PERSIST'; +} + +export type StoreActionWithTogglePersist = StoreAction | TogglePersistAction; + +export interface ExpandedUpdateStateAction extends UpdateStateAction { + readonly instances: InstancesState; +} + +export type WindowStoreAction = + | StoreActionWithoutUpdateState + | TogglePersistAction + | ExpandedUpdateStateAction + | NAAction + | EmptyUpdateStateAction; + +export default function configureStore( + baseStore: Store, + position: string, + preloadedState: PreloadedState +) { + let enhancer: StoreEnhancer; const middlewares = [exportState, api, syncStores(baseStore)]; if (!position || position === '#popup') { // select current tab instance for devPanel and pageAction @@ -20,7 +58,7 @@ export default function configureStore(baseStore, position, preloadedState) { applyMiddleware(...middlewares), window.__REDUX_DEVTOOLS_EXTENSION__ ? window.__REDUX_DEVTOOLS_EXTENSION__() - : (noop) => noop + : (noop: unknown) => noop ); } const store = createStore(rootReducer, preloadedState, enhancer); @@ -33,7 +71,7 @@ export default function configureStore(baseStore, position, preloadedState) { hostname: options['s:hostname'], port: options['s:port'], secure: options['s:secure'], - }, + } as any, }); }); diff --git a/extension/src/browser/extension/background/contextMenus.js b/extension/src/browser/extension/background/contextMenus.ts similarity index 89% rename from extension/src/browser/extension/background/contextMenus.js rename to extension/src/browser/extension/background/contextMenus.ts index a439e4fd..070a1a3b 100644 --- a/extension/src/browser/extension/background/contextMenus.js +++ b/extension/src/browser/extension/background/contextMenus.ts @@ -12,10 +12,10 @@ export function createMenu() { { id: 'devtools-remote', title: 'Open Remote DevTools' }, ]; - let shortcuts = {}; + let shortcuts: { [commandName: string]: string | undefined } = {}; chrome.commands.getAll((commands) => { commands.forEach(({ name, shortcut }) => { - shortcuts[name] = shortcut; + shortcuts[name!] = shortcut; }); menus.forEach(({ id, title }) => { diff --git a/extension/src/browser/extension/background/getPreloadedState.js b/extension/src/browser/extension/background/getPreloadedState.ts similarity index 72% rename from extension/src/browser/extension/background/getPreloadedState.js rename to extension/src/browser/extension/background/getPreloadedState.ts index 89d49d50..e4cfa83a 100644 --- a/extension/src/browser/extension/background/getPreloadedState.js +++ b/extension/src/browser/extension/background/getPreloadedState.ts @@ -1,11 +1,17 @@ -const getIfExists = (sel, template) => +import { PreloadedState } from 'redux'; +import { StoreState } from '@redux-devtools/app/lib/reducers'; + +const getIfExists = (sel: any, template: any) => typeof sel === 'undefined' || typeof template === 'undefined' || typeof template[sel] === 'undefined' ? 0 : sel; -export default function getPreloadedState(position, cb) { +export default function getPreloadedState( + position: string, + cb: (state: PreloadedState) => void +) { chrome.storage.local.get( [ 'monitor' + position, @@ -28,7 +34,7 @@ export default function getPreloadedState(position, cb) { ), templates: options['test-templates'], }, - }); + } as any); } ); } diff --git a/extension/src/browser/extension/background/index.js b/extension/src/browser/extension/background/index.ts similarity index 64% rename from extension/src/browser/extension/background/index.js rename to extension/src/browser/extension/background/index.ts index f92a9af5..165b1af0 100644 --- a/extension/src/browser/extension/background/index.js +++ b/extension/src/browser/extension/background/index.ts @@ -1,7 +1,17 @@ -import configureStore from '../../../app/stores/backgroundStore'; -import openDevToolsWindow from './openWindow'; +import { Store } from 'redux'; +import configureStore, { + BackgroundAction, +} from '../../../app/stores/backgroundStore'; +import openDevToolsWindow, { DevToolsPosition } from './openWindow'; import { createMenu, removeMenu } from './contextMenus'; import syncOptions from '../options/syncOptions'; +import { BackgroundState } from '../../../app/reducers/background'; + +declare global { + interface Window { + store: Store; + } +} // Expose the extension's store globally to access it from the windows // via chrome.runtime.getBackgroundPage @@ -9,7 +19,7 @@ window.store = configureStore(); // Listen for keyboard shortcuts chrome.commands.onCommand.addListener((shortcut) => { - openDevToolsWindow(shortcut); + openDevToolsWindow(shortcut as DevToolsPosition); }); // Create the context menu when installed diff --git a/extension/src/browser/extension/background/logging.js b/extension/src/browser/extension/background/logging.ts similarity index 91% rename from extension/src/browser/extension/background/logging.js rename to extension/src/browser/extension/background/logging.ts index bc5a231c..38735253 100644 --- a/extension/src/browser/extension/background/logging.js +++ b/extension/src/browser/extension/background/logging.ts @@ -1,6 +1,10 @@ import { LIFTED_ACTION } from '@redux-devtools/app/lib/constants/actionTypes'; -export function getReport(reportId, tabId, instanceId) { +export function getReport( + reportId: string, + tabId: string | number, + instanceId: number +) { chrome.storage.local.get(['s:hostname', 's:port', 's:secure'], (options) => { if (!options['s:hostname'] || !options['s:port']) return; const url = `${options['s:secure'] ? 'https' : 'http'}://${ diff --git a/extension/src/browser/extension/background/openWindow.js b/extension/src/browser/extension/background/openWindow.ts similarity index 58% rename from extension/src/browser/extension/background/openWindow.js rename to extension/src/browser/extension/background/openWindow.ts index 4b81564f..2793ff8f 100644 --- a/extension/src/browser/extension/background/openWindow.js +++ b/extension/src/browser/extension/background/openWindow.ts @@ -1,9 +1,20 @@ -let windows = {}; -let lastPosition = null; +export type DevToolsPosition = + | 'devtools-left' + | 'devtools-right' + | 'devtools-bottom' + | 'devtools-panel' + | 'devtools-remote'; -export default function openDevToolsWindow(position) { - function popWindow(action, url, customOptions) { - function focusIfExist(callback) { +let windows: { [K in DevToolsPosition]?: number } = {}; +let lastPosition: DevToolsPosition | null = null; + +export default function openDevToolsWindow(position: DevToolsPosition) { + function popWindow( + action: string, + url: string, + customOptions: chrome.windows.CreateData & chrome.windows.UpdateInfo + ) { + function focusIfExist(callback: () => void) { if (!windows[position]) { callback(); lastPosition = position; @@ -12,7 +23,7 @@ export default function openDevToolsWindow(position) { if (lastPosition !== position && position !== 'devtools-panel') { params = { ...params, ...customOptions }; } - chrome.windows.update(windows[position], params, () => { + chrome.windows.update(windows[position]!, params, () => { lastPosition = null; if (chrome.runtime.lastError) callback(); }); @@ -20,7 +31,7 @@ export default function openDevToolsWindow(position) { } focusIfExist(() => { - let options = { + let options: chrome.windows.CreateData = { type: 'popup', ...customOptions, }; @@ -29,16 +40,19 @@ export default function openDevToolsWindow(position) { url + '#' + position.substr(position.indexOf('-') + 1) ); chrome.windows.create(options, (win) => { - windows[position] = win.id; + windows[position] = win!.id; if (navigator.userAgent.indexOf('Firefox') !== -1) { - chrome.windows.update(win.id, { focused: true, ...customOptions }); + chrome.windows.update(win!.id!, { + focused: true, + ...customOptions, + }); } }); } }); } - let params = { + let params: chrome.windows.CreateData & chrome.windows.UpdateInfo = { left: 0, top: 0, width: 380, @@ -48,7 +62,9 @@ export default function openDevToolsWindow(position) { switch (position) { case 'devtools-right': params.left = - window.screen.availLeft + window.screen.availWidth - params.width; + (window.screen as unknown as { availLeft: number }).availLeft + + window.screen.availWidth - + params.width!; break; case 'devtools-bottom': params.height = 420; diff --git a/extension/src/browser/extension/chromeAPIMock.js b/extension/src/browser/extension/chromeAPIMock.ts similarity index 67% rename from extension/src/browser/extension/chromeAPIMock.js rename to extension/src/browser/extension/chromeAPIMock.ts index 1081f37a..0ee79b89 100644 --- a/extension/src/browser/extension/chromeAPIMock.js +++ b/extension/src/browser/extension/chromeAPIMock.ts @@ -1,48 +1,48 @@ // Mock not supported chrome.* API for Firefox and Electron -window.isElectron = +(window as any).isElectron = window.navigator && window.navigator.userAgent.indexOf('Electron') !== -1; const isFirefox = navigator.userAgent.indexOf('Firefox') !== -1; // Background page only if ( - (window.isElectron && + ((window as any).isElectron && location.pathname === '/_generated_background_page.html') || isFirefox ) { - chrome.runtime.onConnectExternal = { + (chrome.runtime as any).onConnectExternal = { addListener() {}, }; - chrome.runtime.onMessageExternal = { + (chrome.runtime as any).onMessageExternal = { addListener() {}, }; - if (window.isElectron) { - chrome.notifications = { + if ((window as any).isElectron) { + (chrome.notifications as any) = { onClicked: { addListener() {}, }, create() {}, clear() {}, }; - chrome.contextMenus = { + (chrome.contextMenus as any) = { onClicked: { addListener() {}, }, }; } else { - chrome.storage.sync = chrome.storage.local; - chrome.runtime.onInstalled = { - addListener: (cb) => cb(), + (chrome.storage as any).sync = chrome.storage.local; + (chrome.runtime as any).onInstalled = { + addListener: (cb: any) => cb(), }; } } -if (window.isElectron) { +if ((window as any).isElectron) { if (!chrome.storage.local || !chrome.storage.local.remove) { - chrome.storage.local = { - set(obj, callback) { + (chrome.storage as any).local = { + set(obj: any, callback: any) { Object.keys(obj).forEach((key) => { localStorage.setItem(key, obj[key]); }); @@ -50,8 +50,8 @@ if (window.isElectron) { callback(); } }, - get(obj, callback) { - const result = {}; + get(obj: any, callback: any) { + const result: any = {}; Object.keys(obj).forEach((key) => { result[key] = localStorage.getItem(key) || obj[key]; }); @@ -60,7 +60,7 @@ if (window.isElectron) { } }, // Electron ~ 1.4.6 - remove(items, callback) { + remove(items: any, callback: any) { if (Array.isArray(items)) { items.forEach((name) => { localStorage.removeItem(name); @@ -75,7 +75,7 @@ if (window.isElectron) { }; } // Avoid error: chrome.runtime.sendMessage is not supported responseCallback - const originSendMessage = chrome.runtime.sendMessage; + const originSendMessage = (chrome.runtime as any).sendMessage; chrome.runtime.sendMessage = function () { if (process.env.NODE_ENV === 'development') { return originSendMessage(...arguments); @@ -87,6 +87,6 @@ if (window.isElectron) { }; } -if (isFirefox || window.isElectron) { - chrome.storage.sync = chrome.storage.local; +if (isFirefox || (window as any).isElectron) { + (chrome.storage as any).sync = chrome.storage.local; } diff --git a/extension/src/browser/extension/devpanel/index.js b/extension/src/browser/extension/devpanel/index.tsx similarity index 66% rename from extension/src/browser/extension/devpanel/index.js rename to extension/src/browser/extension/devpanel/index.tsx index b9957d1c..484e5e38 100644 --- a/extension/src/browser/extension/devpanel/index.js +++ b/extension/src/browser/extension/devpanel/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { CSSProperties } from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { Provider } from 'react-redux'; import { REMOVE_INSTANCE } from '@redux-devtools/app/lib/constants/actionTypes'; @@ -7,15 +7,23 @@ import configureStore from '../../../app/stores/panelStore'; import getPreloadedState from '../background/getPreloadedState'; import '../../views/devpanel.pug'; +import { Action, PreloadedState, Store } from 'redux'; +import { StoreState } from '@redux-devtools/app/lib/reducers'; +import { PanelMessage } from '../../../app/middlewares/api'; +import { StoreActionWithTogglePersist } from '../../../app/stores/windowStore'; const position = location.hash; -const messageStyle = { padding: '20px', width: '100%', textAlign: 'center' }; +const messageStyle: CSSProperties = { + padding: '20px', + width: '100%', + textAlign: 'center', +}; -let rendered; -let store; -let bgConnection; -let naTimeout; -let preloadedState; +let rendered: boolean | undefined; +let store: Store | undefined; +let bgConnection: chrome.runtime.Port; +let naTimeout: NodeJS.Timeout; +let preloadedState: PreloadedState; const isChrome = navigator.userAgent.indexOf('Firefox') === -1; @@ -25,7 +33,7 @@ getPreloadedState(position, (state) => { function renderDevTools() { const node = document.getElementById('root'); - unmountComponentAtNode(node); + unmountComponentAtNode(node!); clearTimeout(naTimeout); store = configureStore(position, bgConnection, preloadedState); render( @@ -71,33 +79,35 @@ function renderNA() { } const node = document.getElementById('root'); - unmountComponentAtNode(node); + unmountComponentAtNode(node!); render(message, node); store = undefined; }); } else { const node = document.getElementById('root'); - unmountComponentAtNode(node); + unmountComponentAtNode(node!); render(message, node); store = undefined; } }, 3500); } -function init(id) { +function init(id: number) { renderNA(); bgConnection = chrome.runtime.connect({ name: id ? id.toString() : undefined, }); - bgConnection.onMessage.addListener((message) => { - if (message.type === 'NA') { - if (message.id === id) renderNA(); - else store.dispatch({ type: REMOVE_INSTANCE, id: message.id }); - } else { - if (!rendered) renderDevTools(); - store.dispatch(message); + bgConnection.onMessage.addListener( + >(message: PanelMessage) => { + if (message.type === 'NA') { + if (message.id === id) renderNA(); + else store!.dispatch({ type: REMOVE_INSTANCE, id: message.id }); + } else { + if (!rendered) renderDevTools(); + store!.dispatch(message); + } } - }); + ); } init(chrome.devtools.inspectedWindow.tabId); diff --git a/extension/src/browser/extension/devtools/index.js b/extension/src/browser/extension/devtools/index.ts similarity index 92% rename from extension/src/browser/extension/devtools/index.js rename to extension/src/browser/extension/devtools/index.ts index 9365ec61..de6abaaa 100644 --- a/extension/src/browser/extension/devtools/index.js +++ b/extension/src/browser/extension/devtools/index.ts @@ -1,6 +1,6 @@ import '../../views/devtools.pug'; -function createPanel(url) { +function createPanel(url: string) { chrome.devtools.panels.create( 'Redux', 'img/logo/scalable.png', diff --git a/extension/src/browser/extension/inject/contentScript.js b/extension/src/browser/extension/inject/contentScript.js deleted file mode 100644 index 5f59d5da..00000000 --- a/extension/src/browser/extension/inject/contentScript.js +++ /dev/null @@ -1,132 +0,0 @@ -import { - injectOptions, - getOptionsFromBg, - isAllowed, -} from '../options/syncOptions'; -const source = '@devtools-extension'; -const pageSource = '@devtools-page'; -// Chrome message limit is 64 MB, but we're using 32 MB to include other object's parts -const maxChromeMsgSize = 32 * 1024 * 1024; -let connected = false; -let bg; - -function connect() { - // Connect to the background script - connected = true; - const name = 'tab'; - if (window.devToolsExtensionID) { - bg = chrome.runtime.connect(window.devToolsExtensionID, { name }); - } else { - bg = chrome.runtime.connect({ name }); - } - - // Relay background script messages to the page script - bg.onMessage.addListener((message) => { - if (message.action) { - window.postMessage( - { - type: message.type, - payload: message.action, - state: message.state, - id: message.id, - source, - }, - '*' - ); - } else if (message.options) { - injectOptions(message.options); - } else { - window.postMessage( - { - type: message.type, - state: message.state, - id: message.id, - source, - }, - '*' - ); - } - }); - - bg.onDisconnect.addListener(handleDisconnect); -} - -function handleDisconnect() { - window.removeEventListener('message', handleMessages); - window.postMessage({ type: 'STOP', failed: true, source }, '*'); - bg = undefined; -} - -function tryCatch(fn, args) { - try { - return fn(args); - } catch (err) { - if (err.message === 'Message length exceeded maximum allowed length.') { - const instanceId = args.instanceId; - const newArgs = { split: 'start' }; - const toSplit = []; - let size = 0; - let arg; - Object.keys(args).map((key) => { - arg = args[key]; - if (typeof arg === 'string') { - size += arg.length; - if (size > maxChromeMsgSize) { - toSplit.push([key, arg]); - return; - } - } - newArgs[key] = arg; - }); - fn(newArgs); - for (let i = 0; i < toSplit.length; i++) { - for (let j = 0; j < toSplit[i][1].length; j += maxChromeMsgSize) { - fn({ - instanceId, - source: pageSource, - split: 'chunk', - chunk: [toSplit[i][0], toSplit[i][1].substr(j, maxChromeMsgSize)], - }); - } - } - return fn({ instanceId, source: pageSource, split: 'end' }); - } - handleDisconnect(); - /* eslint-disable no-console */ - if (process.env.NODE_ENV !== 'production') { - console.error('Failed to send message', err); - } - /* eslint-enable no-console */ - } -} - -function send(message) { - if (!connected) connect(); - if (message.type === 'INIT_INSTANCE') { - getOptionsFromBg(); - bg.postMessage({ name: 'INIT_INSTANCE', instanceId: message.instanceId }); - } else { - bg.postMessage({ name: 'RELAY', message }); - } -} - -// Resend messages from the page to the background script -function handleMessages(event) { - if (!isAllowed()) return; - if (!event || event.source !== window || typeof event.data !== 'object') { - return; - } - const message = event.data; - if (message.source !== pageSource) return; - if (message.type === 'DISCONNECT') { - if (bg) { - bg.disconnect(); - connected = false; - } - return; - } - - tryCatch(send, message); -} - -window.addEventListener('message', handleMessages, false); diff --git a/extension/src/browser/extension/inject/contentScript.ts b/extension/src/browser/extension/inject/contentScript.ts new file mode 100644 index 00000000..c1abac34 --- /dev/null +++ b/extension/src/browser/extension/inject/contentScript.ts @@ -0,0 +1,313 @@ +import { + injectOptions, + getOptionsFromBg, + isAllowed, +} from '../options/syncOptions'; +import { TabMessage } from '../../../app/middlewares/api'; +import { + PageScriptToContentScriptMessage, + PageScriptToContentScriptMessageWithoutDisconnect, + PageScriptToContentScriptMessageWithoutDisconnectOrInitInstance, +} from '../../../app/api'; +import { Action } from 'redux'; +import { + CustomAction, + DispatchAction as AppDispatchAction, +} from '@redux-devtools/app/lib/actions'; +import { LiftedState } from '@redux-devtools/instrument'; +const source = '@devtools-extension'; +const pageSource = '@devtools-page'; +// Chrome message limit is 64 MB, but we're using 32 MB to include other object's parts +const maxChromeMsgSize = 32 * 1024 * 1024; +let connected = false; +let bg: chrome.runtime.Port | undefined; + +declare global { + interface Window { + devToolsExtensionID?: string; + } +} + +interface StartAction { + readonly type: 'START'; + readonly state: undefined; + readonly id: undefined; + readonly source: typeof source; +} + +interface StopAction { + readonly type: 'STOP'; + readonly state: undefined; + readonly id: undefined; + readonly source: typeof source; + readonly failed?: boolean; +} + +interface DispatchAction { + readonly type: 'DISPATCH'; + readonly payload: AppDispatchAction; + readonly state: string | undefined; + readonly id: string; + readonly source: typeof source; +} + +interface ImportAction { + readonly type: 'IMPORT'; + readonly payload: undefined; + readonly state: string | undefined; + readonly id: string; + readonly source: typeof source; +} + +interface ActionAction { + readonly type: 'ACTION'; + readonly payload: string | CustomAction; + readonly state: string | undefined; + readonly id: string; + readonly source: typeof source; +} + +interface ExportAction { + readonly type: 'EXPORT'; + readonly payload: undefined; + readonly state: string | undefined; + readonly id: string; + readonly source: typeof source; +} + +interface UpdateAction { + readonly type: 'UPDATE'; + readonly state: string | undefined; + readonly id: string; + readonly source: typeof source; +} + +export type ContentScriptToPageScriptMessage = + | StartAction + | StopAction + | DispatchAction + | ImportAction + | ActionAction + | ExportAction + | UpdateAction; + +interface ImportStatePayload> { + readonly type: 'IMPORT_STATE'; + readonly nextLiftedState: LiftedState | readonly A[]; + readonly preloadedState?: S; +} + +interface ImportStateDispatchAction> { + readonly type: 'DISPATCH'; + readonly payload: ImportStatePayload; +} + +export type ListenerMessage> = + | StartAction + | StopAction + | DispatchAction + | ImportAction + | ActionAction + | ExportAction + | UpdateAction + | ImportStateDispatchAction; + +function postToPageScript(message: ContentScriptToPageScriptMessage) { + window.postMessage(message, '*'); +} + +function connect() { + // Connect to the background script + connected = true; + const name = 'tab'; + if (window.devToolsExtensionID) { + bg = chrome.runtime.connect(window.devToolsExtensionID, { name }); + } else { + bg = chrome.runtime.connect({ name }); + } + + // Relay background script messages to the page script + bg.onMessage.addListener((message: TabMessage) => { + if ('action' in message) { + if (message.type === 'DISPATCH') { + postToPageScript({ + type: message.type, + payload: message.action, + state: message.state, + id: message.id, + source, + }); + } else if (message.type === 'ACTION') { + postToPageScript({ + type: message.type, + payload: message.action, + state: message.state, + id: message.id, + source, + }); + } else { + postToPageScript({ + type: message.type, + payload: message.action, + state: message.state, + id: message.id, + source, + }); + } + } else if ('options' in message) { + injectOptions(message.options); + } else { + postToPageScript({ + type: message.type, + state: message.state, + id: message.id, + source, + }); + } + }); + + bg.onDisconnect.addListener(handleDisconnect); +} + +function handleDisconnect() { + window.removeEventListener('message', handleMessages); + window.postMessage({ type: 'STOP', failed: true, source }, '*'); + bg = undefined; +} + +interface SplitMessageBase { + readonly type?: never; +} + +interface SplitMessageStart extends SplitMessageBase { + readonly instanceId: number; + readonly source: typeof pageSource; + readonly split: 'start'; +} + +interface SplitMessageChunk extends SplitMessageBase { + readonly instanceId: number; + readonly source: typeof pageSource; + readonly split: 'chunk'; + readonly chunk: [string, string]; +} + +interface SplitMessageEnd extends SplitMessageBase { + readonly instanceId: number; + readonly source: typeof pageSource; + readonly split: 'end'; +} + +export type SplitMessage = + | SplitMessageStart + | SplitMessageChunk + | SplitMessageEnd; + +function tryCatch>( + fn: ( + args: PageScriptToContentScriptMessageWithoutDisconnect | SplitMessage + ) => void, + args: PageScriptToContentScriptMessageWithoutDisconnect +) { + try { + return fn(args); + } catch (err) { + if (err.message === 'Message length exceeded maximum allowed length.') { + const instanceId = (args as any).instanceId; + const newArgs = { + split: 'start', + }; + const toSplit: [string, string][] = []; + let size = 0; + let arg; + Object.keys(args).map((key) => { + arg = args[key as keyof typeof args]; + if (typeof arg === 'string') { + size += arg.length; + if (size > maxChromeMsgSize) { + toSplit.push([key, arg]); + return; + } + } + newArgs[key as keyof typeof newArgs] = arg; + }); + fn(newArgs as any); + for (let i = 0; i < toSplit.length; i++) { + for (let j = 0; j < toSplit[i][1].length; j += maxChromeMsgSize) { + fn({ + instanceId, + source: pageSource, + split: 'chunk', + chunk: [toSplit[i][0], toSplit[i][1].substr(j, maxChromeMsgSize)], + }); + } + } + return fn({ instanceId, source: pageSource, split: 'end' }); + } + handleDisconnect(); + /* eslint-disable no-console */ + if (process.env.NODE_ENV !== 'production') { + console.error('Failed to send message', err); + } + /* eslint-enable no-console */ + } +} + +interface InitInstanceContentScriptToBackgroundMessage { + readonly name: 'INIT_INSTANCE'; + readonly instanceId: number; +} + +interface RelayMessage> { + readonly name: 'RELAY'; + readonly message: + | PageScriptToContentScriptMessageWithoutDisconnectOrInitInstance + | SplitMessage; +} + +export type ContentScriptToBackgroundMessage> = + | InitInstanceContentScriptToBackgroundMessage + | RelayMessage; + +function postToBackground>( + message: ContentScriptToBackgroundMessage +) { + bg!.postMessage(message); +} + +function send>( + message: + | PageScriptToContentScriptMessageWithoutDisconnect + | SplitMessage +) { + if (!connected) connect(); + if (message.type === 'INIT_INSTANCE') { + getOptionsFromBg(); + postToBackground({ name: 'INIT_INSTANCE', instanceId: message.instanceId }); + } else { + postToBackground({ name: 'RELAY', message }); + } +} + +// Resend messages from the page to the background script +function handleMessages>( + event: MessageEvent> +) { + if (!isAllowed()) return; + if (!event || event.source !== window || typeof event.data !== 'object') { + return; + } + const message = event.data; + if (message.source !== pageSource) return; + if (message.type === 'DISCONNECT') { + if (bg) { + bg.disconnect(); + connected = false; + } + return; + } + + tryCatch(send, message); +} + +window.addEventListener('message', handleMessages, false); diff --git a/extension/src/browser/extension/inject/deprecatedWarn.js b/extension/src/browser/extension/inject/deprecatedWarn.ts similarity index 100% rename from extension/src/browser/extension/inject/deprecatedWarn.js rename to extension/src/browser/extension/inject/deprecatedWarn.ts diff --git a/extension/src/browser/extension/inject/index.js b/extension/src/browser/extension/inject/index.ts similarity index 86% rename from extension/src/browser/extension/inject/index.js rename to extension/src/browser/extension/inject/index.ts index 9537c081..dfb369b7 100644 --- a/extension/src/browser/extension/inject/index.js +++ b/extension/src/browser/extension/inject/index.ts @@ -1,6 +1,8 @@ // Include this script in Chrome apps and extensions for remote debugging // +import { Options } from '../options/syncOptions'; + window.devToolsExtensionID = 'lmhkpmbekcpmknklioeibfkpmmfibljd'; require('./contentScript'); require('./pageScript'); @@ -8,7 +10,7 @@ require('./pageScript'); chrome.runtime.sendMessage( window.devToolsExtensionID, { type: 'GET_OPTIONS' }, - function (response) { + function (response: { readonly options: Options }) { if (!response.options.inject) { const urls = response.options.urls.split('\n').filter(Boolean).join('|'); if (!location.href.match(new RegExp(urls))) return; diff --git a/extension/src/browser/extension/inject/pageScript.js b/extension/src/browser/extension/inject/pageScript.js deleted file mode 100644 index a2b6d5a1..00000000 --- a/extension/src/browser/extension/inject/pageScript.js +++ /dev/null @@ -1,486 +0,0 @@ -import { getActionsArray, evalAction } from '@redux-devtools/utils'; -import throttle from 'lodash/throttle'; -import createStore from '../../../app/stores/createStore'; -import configureStore, { getUrlParam } from '../../../app/stores/enhancerStore'; -import { isAllowed } from '../options/syncOptions'; -import Monitor from '../../../app/service/Monitor'; -import { - noFiltersApplied, - getLocalFilter, - isFiltered, - filterState, - startingFrom, -} from '../../../app/api/filters'; -import notifyErrors from '../../../app/api/notifyErrors'; -import importState from '../../../app/api/importState'; -import openWindow from '../../../app/api/openWindow'; -import generateId from '../../../app/api/generateInstanceId'; -import { - updateStore, - toContentScript, - sendMessage, - setListener, - connect, - disconnect, - isInIframe, - getSeralizeParameter, -} from '../../../app/api'; - -const source = '@devtools-page'; -let stores = {}; -let reportId; - -function deprecateParam(oldParam, newParam) { - /* eslint-disable no-console */ - console.warn( - `${oldParam} parameter is deprecated, use ${newParam} instead: https://github.com/zalmoxisus/redux-devtools-extension/blob/master/docs/API/Arguments.md` - ); - /* eslint-enable no-console */ -} - -const __REDUX_DEVTOOLS_EXTENSION__ = function ( - reducer, - preloadedState, - config -) { - /* eslint-disable no-param-reassign */ - if (typeof reducer === 'object') { - config = reducer; - reducer = undefined; - } else if (typeof config !== 'object') config = {}; - /* eslint-enable no-param-reassign */ - if (!window.devToolsOptions) window.devToolsOptions = {}; - - let store; - let errorOccurred = false; - let maxAge; - let actionCreators; - let sendingActionId = 1; - const instanceId = generateId(config.instanceId); - const localFilter = getLocalFilter(config); - const serializeState = getSeralizeParameter(config, 'serializeState'); - const serializeAction = getSeralizeParameter(config, 'serializeAction'); - let { - statesFilter, - actionsFilter, - stateSanitizer, - actionSanitizer, - predicate, - latency = 500, - } = config; - - // Deprecate statesFilter and actionsFilter - if (statesFilter) { - deprecateParam('statesFilter', 'stateSanitizer'); - stateSanitizer = statesFilter; // eslint-disable-line no-param-reassign - } - if (actionsFilter) { - deprecateParam('actionsFilter', 'actionSanitizer'); - actionSanitizer = actionsFilter; // eslint-disable-line no-param-reassign - } - - const monitor = new Monitor(relayState); - if (config.getMonitor) { - /* eslint-disable no-console */ - console.warn( - "Redux DevTools extension's `getMonitor` parameter is deprecated and will be not " + - 'supported in the next version, please remove it and just use ' + - '`__REDUX_DEVTOOLS_EXTENSION_COMPOSE__` instead: ' + - 'https://github.com/zalmoxisus/redux-devtools-extension#12-advanced-store-setup' - ); - /* eslint-enable no-console */ - config.getMonitor(monitor); - } - - function exportState() { - const liftedState = store.liftedStore.getState(); - const actionsById = liftedState.actionsById; - const payload = []; - liftedState.stagedActionIds.slice(1).forEach((id) => { - // if (isFiltered(actionsById[id].action, localFilter)) return; - payload.push(actionsById[id].action); - }); - toContentScript( - { - type: 'EXPORT', - payload, - committedState: liftedState.committedState, - source, - instanceId, - }, - serializeState, - serializeAction - ); - } - - function relay(type, state, action, nextActionId, libConfig) { - const message = { - type, - payload: filterState( - state, - type, - localFilter, - stateSanitizer, - actionSanitizer, - nextActionId, - predicate - ), - source, - instanceId, - }; - - if (type === 'ACTION') { - message.action = !actionSanitizer - ? action - : actionSanitizer(action.action, nextActionId - 1); - message.maxAge = getMaxAge(); - message.nextActionId = nextActionId; - } else if (libConfig) { - message.libConfig = libConfig; - } - - toContentScript(message, serializeState, serializeAction); - } - - const relayState = throttle((liftedState, libConfig) => { - relayAction.cancel(); - const state = liftedState || store.liftedStore.getState(); - sendingActionId = state.nextActionId; - relay('STATE', state, undefined, undefined, libConfig); - }, latency); - - const relayAction = throttle(() => { - const liftedState = store.liftedStore.getState(); - const nextActionId = liftedState.nextActionId; - const currentActionId = nextActionId - 1; - const liftedAction = liftedState.actionsById[currentActionId]; - - // Send a single action - if (sendingActionId === currentActionId) { - sendingActionId = nextActionId; - const action = liftedAction.action; - const computedStates = liftedState.computedStates; - if ( - isFiltered(action, localFilter) || - (predicate && - !predicate(computedStates[computedStates.length - 1].state, action)) - ) { - return; - } - const state = - liftedState.computedStates[liftedState.computedStates.length - 1].state; - relay( - 'ACTION', - state, - liftedState.actionsById[nextActionId - 1], - nextActionId - ); - return; - } - - // Send multiple actions - const payload = startingFrom( - sendingActionId, - liftedState, - localFilter, - stateSanitizer, - actionSanitizer, - predicate - ); - sendingActionId = nextActionId; - if (typeof payload === 'undefined') return; - if (typeof payload.skippedActionIds !== 'undefined') { - relay('STATE', payload); - return; - } - toContentScript( - { - type: 'PARTIAL_STATE', - payload, - source, - instanceId, - maxAge: getMaxAge(), - }, - serializeState, - serializeAction - ); - }, latency); - - function dispatchRemotely(action) { - if (config.features && !config.features.dispatch) return; - try { - const result = evalAction(action, actionCreators); - (store.initialDispatch || store.dispatch)(result); - } catch (e) { - relay('ERROR', e.message); - } - } - - function importPayloadFrom(state) { - if (config.features && !config.features.import) return; - try { - const nextLiftedState = importState(state, config); - if (!nextLiftedState) return; - store.liftedStore.dispatch({ type: 'IMPORT_STATE', ...nextLiftedState }); - } catch (e) { - relay('ERROR', e.message); - } - } - - function dispatchMonitorAction(action) { - const type = action.type; - const features = config.features; - if (features) { - if ( - !features.jump && - (type === 'JUMP_TO_STATE' || type === 'JUMP_TO_ACTION') - ) { - return; - } - if (!features.skip && type === 'TOGGLE_ACTION') return; - if (!features.reorder && type === 'REORDER_ACTION') return; - if (!features.import && type === 'IMPORT_STATE') return; - if (!features.lock && type === 'LOCK_CHANGES') return; - if (!features.pause && type === 'PAUSE_RECORDING') return; - } - if (type === 'JUMP_TO_STATE') { - const liftedState = store.liftedStore.getState(); - const index = liftedState.stagedActionIds.indexOf(action.actionId); - if (index === -1) return; - store.liftedStore.dispatch({ type, index }); - return; - } - store.liftedStore.dispatch(action); - } - - function onMessage(message) { - switch (message.type) { - case 'DISPATCH': - dispatchMonitorAction(message.payload); - return; - case 'ACTION': - dispatchRemotely(message.payload); - return; - case 'IMPORT': - importPayloadFrom(message.state); - return; - case 'EXPORT': - exportState(); - return; - case 'UPDATE': - relayState(); - return; - case 'START': - monitor.start(true); - if (!actionCreators && config.actionCreators) { - actionCreators = getActionsArray(config.actionCreators); - } - relayState(undefined, { - name: config.name || document.title, - actionCreators: JSON.stringify(actionCreators), - features: config.features, - serialize: !!config.serialize, - type: 'redux', - }); - - if (reportId) { - relay('GET_REPORT', reportId); - reportId = null; - } - return; - case 'STOP': - monitor.stop(); - relayAction.cancel(); - relayState.cancel(); - if (!message.failed) relay('STOP'); - } - } - - const filteredActionIds = []; // simple circular buffer of non-excluded actions with fixed maxAge-1 length - const getMaxAge = (liftedAction, liftedState) => { - let m = (config && config.maxAge) || window.devToolsOptions.maxAge || 50; - if ( - !liftedAction || - noFiltersApplied(localFilter) || - !liftedAction.action - ) { - return m; - } - if (!maxAge || maxAge < m) maxAge = m; // it can be modified in process on options page - if (isFiltered(liftedAction.action, localFilter)) { - // TODO: check also predicate && !predicate(state, action) with current state - maxAge++; - } else { - filteredActionIds.push(liftedState.nextActionId); - if (filteredActionIds.length >= m) { - const stagedActionIds = liftedState.stagedActionIds; - let i = 1; - while ( - maxAge > m && - filteredActionIds.indexOf(stagedActionIds[i]) === -1 - ) { - maxAge--; - i++; - } - filteredActionIds.shift(); - } - } - return maxAge; - }; - - function init() { - setListener(onMessage, instanceId); - notifyErrors(() => { - errorOccurred = true; - const state = store.liftedStore.getState(); - if (state.computedStates[state.currentStateIndex].error) { - relayState(state); - } - return true; - }); - - relay('INIT_INSTANCE'); - store.subscribe(handleChange); - - if (typeof reportId === 'undefined') { - reportId = getUrlParam('remotedev_report'); - if (reportId) openWindow(); - } - } - - function handleChange() { - if (!monitor.active) return; - if (!errorOccurred && !monitor.isMonitorAction()) { - relayAction(); - return; - } - if (monitor.isPaused() || monitor.isLocked() || monitor.isTimeTraveling()) { - return; - } - const liftedState = store.liftedStore.getState(); - if ( - errorOccurred && - !liftedState.computedStates[liftedState.currentStateIndex].error - ) { - errorOccurred = false; - } - relayState(liftedState); - } - - const enhance = () => (next) => { - return (reducer_, initialState_, enhancer_) => { - if (!isAllowed(window.devToolsOptions)) { - return next(reducer_, initialState_, enhancer_); - } - - store = stores[instanceId] = configureStore(next, monitor.reducer, { - ...config, - maxAge: getMaxAge, - })(reducer_, initialState_, enhancer_); - - if (isInIframe()) setTimeout(init, 3000); - else init(); - - return store; - }; - }; - - if (!reducer) return enhance(); - /* eslint-disable no-console */ - console.warn( - 'Creating a Redux store directly from DevTools extension is discouraged and will not be supported in future major version. For more details see: https://git.io/fphCe' - ); - /* eslint-enable no-console */ - return createStore(reducer, preloadedState, enhance); -}; - -// noinspection JSAnnotator -window.__REDUX_DEVTOOLS_EXTENSION__ = __REDUX_DEVTOOLS_EXTENSION__; -window.__REDUX_DEVTOOLS_EXTENSION__.open = openWindow; -window.__REDUX_DEVTOOLS_EXTENSION__.updateStore = updateStore(stores); -window.__REDUX_DEVTOOLS_EXTENSION__.notifyErrors = notifyErrors; -window.__REDUX_DEVTOOLS_EXTENSION__.send = sendMessage; -window.__REDUX_DEVTOOLS_EXTENSION__.listen = setListener; -window.__REDUX_DEVTOOLS_EXTENSION__.connect = connect; -window.__REDUX_DEVTOOLS_EXTENSION__.disconnect = disconnect; - -// Deprecated -/* eslint-disable no-console */ -let varNameDeprecatedWarned; -const varNameDeprecatedWarn = () => { - if (varNameDeprecatedWarned) return; - console.warn( - '`window.devToolsExtension` is deprecated in favor of `window.__REDUX_DEVTOOLS_EXTENSION__`, and will be removed in next version of Redux DevTools: https://git.io/fpEJZ' - ); - varNameDeprecatedWarned = true; -}; -/* eslint-enable no-console */ -window.devToolsExtension = (...args) => { - varNameDeprecatedWarn(); - return __REDUX_DEVTOOLS_EXTENSION__.apply(null, args); -}; -window.devToolsExtension.open = (...args) => { - varNameDeprecatedWarn(); - return openWindow.apply(null, args); -}; -window.devToolsExtension.updateStore = (...args) => { - varNameDeprecatedWarn(); - return updateStore(stores).apply(null, args); -}; -window.devToolsExtension.notifyErrors = (...args) => { - varNameDeprecatedWarn(); - return notifyErrors.apply(null, args); -}; -window.devToolsExtension.send = (...args) => { - varNameDeprecatedWarn(); - return sendMessage.apply(null, args); -}; -window.devToolsExtension.listen = (...args) => { - varNameDeprecatedWarn(); - return setListener.apply(null, args); -}; -window.devToolsExtension.connect = (...args) => { - varNameDeprecatedWarn(); - return connect.apply(null, args); -}; -window.devToolsExtension.disconnect = (...args) => { - varNameDeprecatedWarn(); - return disconnect.apply(null, args); -}; - -const preEnhancer = - (instanceId) => (next) => (reducer, preloadedState, enhancer) => { - const store = next(reducer, preloadedState, enhancer); - - if (stores[instanceId]) { - stores[instanceId].initialDispatch = store.dispatch; - } - - return { - ...store, - dispatch: (...args) => - !window.__REDUX_DEVTOOLS_EXTENSION_LOCKED__ && store.dispatch(...args), - }; - }; - -const extensionCompose = - (config) => - (...funcs) => { - return (...args) => { - const instanceId = generateId(config.instanceId); - return [preEnhancer(instanceId), ...funcs].reduceRight( - (composed, f) => f(composed), - __REDUX_DEVTOOLS_EXTENSION__({ ...config, instanceId })(...args) - ); - }; - }; - -window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ = (...funcs) => { - if (funcs.length === 0) { - return __REDUX_DEVTOOLS_EXTENSION__(); - } - if (funcs.length === 1 && typeof funcs[0] === 'object') { - return extensionCompose(funcs[0]); - } - return extensionCompose({})(...funcs); -}; diff --git a/extension/src/browser/extension/inject/pageScript.ts b/extension/src/browser/extension/inject/pageScript.ts new file mode 100644 index 00000000..2fb007a9 --- /dev/null +++ b/extension/src/browser/extension/inject/pageScript.ts @@ -0,0 +1,681 @@ +import { + getActionsArray, + evalAction, + ActionCreatorObject, +} from '@redux-devtools/utils'; +import throttle from 'lodash/throttle'; +import { + Action, + ActionCreator, + Dispatch, + PreloadedState, + Reducer, + Store, + StoreEnhancer, + StoreEnhancerStoreCreator, +} from 'redux'; +import Immutable from 'immutable'; +import { EnhancedStore, PerformAction } from '@redux-devtools/instrument'; +import createStore from '../../../app/stores/createStore'; +import configureStore, { getUrlParam } from '../../../app/stores/enhancerStore'; +import { isAllowed, Options } from '../options/syncOptions'; +import Monitor from '../../../app/service/Monitor'; +import { + noFiltersApplied, + getLocalFilter, + isFiltered, + filterState, + startingFrom, +} from '../../../app/api/filters'; +import notifyErrors from '../../../app/api/notifyErrors'; +import importState from '../../../app/api/importState'; +import openWindow, { Position } from '../../../app/api/openWindow'; +import generateId from '../../../app/api/generateInstanceId'; +import { + updateStore, + toContentScript, + sendMessage, + setListener, + connect, + disconnect, + isInIframe, + getSerializeParameter, + Serialize, + StructuralPerformAction, + ConnectResponse, +} from '../../../app/api'; +import { LiftedAction, LiftedState } from '@redux-devtools/instrument'; +import { + CustomAction, + DispatchAction, + LibConfig, +} from '@redux-devtools/app/lib/actions'; +import { ContentScriptToPageScriptMessage } from './contentScript'; +import { Features } from '@redux-devtools/app/lib/reducers/instances'; + +type EnhancedStoreWithInitialDispatch< + S, + A extends Action, + MonitorState +> = EnhancedStore & { initialDispatch: Dispatch }; + +const source = '@devtools-page'; +let stores: { + [K in string | number]: EnhancedStoreWithInitialDispatch< + unknown, + Action, + unknown + >; +} = {}; +let reportId: string | null | undefined; + +function deprecateParam(oldParam: string, newParam: string) { + /* eslint-disable no-console */ + console.warn( + `${oldParam} parameter is deprecated, use ${newParam} instead: https://github.com/zalmoxisus/redux-devtools-extension/blob/master/docs/API/Arguments.md` + ); + /* eslint-enable no-console */ +} + +export interface SerializeWithImmutable extends Serialize { + readonly immutable?: typeof Immutable; + readonly refs?: (new (data: any) => unknown)[] | null; +} + +export interface ConfigWithExpandedMaxAge { + instanceId?: number; + readonly actionsBlacklist?: string | readonly string[]; + readonly actionsWhitelist?: string | readonly string[]; + serialize?: boolean | SerializeWithImmutable; + readonly serializeState?: + | boolean + | ((key: string, value: unknown) => unknown) + | Serialize; + readonly serializeAction?: + | boolean + | ((key: string, value: unknown) => unknown) + | Serialize; + readonly statesFilter?: (state: S, index?: number) => S; + readonly actionsFilter?: >( + action: A, + id?: number + ) => A; + readonly stateSanitizer?: (state: S, index?: number) => S; + readonly actionSanitizer?: >( + action: A, + id?: number + ) => A; + readonly predicate?: >( + state: S, + action: A + ) => boolean; + readonly latency?: number; + readonly getMonitor?: >( + monitor: Monitor + ) => void; + readonly maxAge?: + | number + | (>( + currentLiftedAction: LiftedAction, + previousLiftedState: LiftedState | undefined + ) => number); + readonly trace?: boolean | (() => string | undefined); + readonly traceLimit?: number; + readonly shouldCatchErrors?: boolean; + readonly shouldHotReload?: boolean; + readonly shouldRecordChanges?: boolean; + readonly shouldStartLocked?: boolean; + readonly pauseActionType?: unknown; + readonly deserializeState?: (state: S) => S; + readonly deserializeAction?: >(action: A) => A; + name?: string; + readonly autoPause?: boolean; + readonly features?: Features; + readonly type?: string; + readonly getActionType?: >(action: A) => A; + readonly actionCreators?: { + readonly [key: string]: ActionCreator>; + }; +} + +export interface Config extends ConfigWithExpandedMaxAge { + readonly maxAge?: number; +} + +interface ReduxDevtoolsExtension { + >( + reducer: Reducer, + preloadedState?: PreloadedState, + config?: Config + ): Store; + (config?: Config): StoreEnhancer; + open: (position?: Position) => void; + updateStore: ( + newStore: EnhancedStore, unknown>, + instanceId: number + ) => void; + notifyErrors: (onError?: () => boolean) => void; + send: >( + action: StructuralPerformAction | StructuralPerformAction[], + state: LiftedState, + config: Config, + instanceId?: number, + name?: string + ) => void; + listen: ( + onMessage: (message: ContentScriptToPageScriptMessage) => void, + instanceId: number + ) => void; + connect: (preConfig: Config) => ConnectResponse; + disconnect: () => void; +} + +declare global { + interface Window { + devToolsOptions: Options; + } +} + +function __REDUX_DEVTOOLS_EXTENSION__>( + reducer?: Reducer, + preloadedState?: PreloadedState, + config?: Config +): Store; +function __REDUX_DEVTOOLS_EXTENSION__(config: Config): StoreEnhancer; +function __REDUX_DEVTOOLS_EXTENSION__>( + reducer?: Reducer | Config | undefined, + preloadedState?: PreloadedState, + config?: Config +): Store | StoreEnhancer { + /* eslint-disable no-param-reassign */ + if (typeof reducer === 'object') { + config = reducer; + reducer = undefined; + } else if (typeof config !== 'object') config = {}; + /* eslint-enable no-param-reassign */ + if (!window.devToolsOptions) window.devToolsOptions = {} as any; + + let store: EnhancedStoreWithInitialDispatch; + let errorOccurred = false; + let maxAge: number | undefined; + let actionCreators: readonly ActionCreatorObject[]; + let sendingActionId = 1; + const instanceId = generateId(config.instanceId); + const localFilter = getLocalFilter(config); + const serializeState = getSerializeParameter(config, 'serializeState'); + const serializeAction = getSerializeParameter(config, 'serializeAction'); + let { + statesFilter, + actionsFilter, + stateSanitizer, + actionSanitizer, + predicate, + latency = 500, + } = config; + + // Deprecate statesFilter and actionsFilter + if (statesFilter) { + deprecateParam('statesFilter', 'stateSanitizer'); + stateSanitizer = statesFilter; // eslint-disable-line no-param-reassign + } + if (actionsFilter) { + deprecateParam('actionsFilter', 'actionSanitizer'); + actionSanitizer = actionsFilter; // eslint-disable-line no-param-reassign + } + + const relayState = throttle( + ( + liftedState?: LiftedState | undefined, + libConfig?: LibConfig + ) => { + relayAction.cancel(); + const state = liftedState || store.liftedStore.getState(); + sendingActionId = state.nextActionId; + toContentScript( + { + type: 'STATE', + payload: filterState( + state, + localFilter, + stateSanitizer, + actionSanitizer, + predicate + ), + source, + instanceId, + libConfig, + }, + serializeState, + serializeAction + ); + }, + latency + ); + + const monitor = new Monitor(relayState); + if (config.getMonitor) { + /* eslint-disable no-console */ + console.warn( + "Redux DevTools extension's `getMonitor` parameter is deprecated and will be not " + + 'supported in the next version, please remove it and just use ' + + '`__REDUX_DEVTOOLS_EXTENSION_COMPOSE__` instead: ' + + 'https://github.com/zalmoxisus/redux-devtools-extension#12-advanced-store-setup' + ); + /* eslint-enable no-console */ + config.getMonitor(monitor); + } + + function exportState() { + const liftedState = store.liftedStore.getState(); + const actionsById = liftedState.actionsById; + const payload: A[] = []; + liftedState.stagedActionIds.slice(1).forEach((id) => { + // if (isFiltered(actionsById[id].action, localFilter)) return; + payload.push(actionsById[id].action); + }); + toContentScript( + { + type: 'EXPORT', + payload, + committedState: liftedState.committedState, + source, + instanceId, + }, + serializeState, + serializeAction + ); + } + + const relayAction = throttle(() => { + const liftedState = store.liftedStore.getState(); + const nextActionId = liftedState.nextActionId; + const currentActionId = nextActionId - 1; + const liftedAction = liftedState.actionsById[currentActionId]; + + // Send a single action + if (sendingActionId === currentActionId) { + sendingActionId = nextActionId; + const action = liftedAction.action; + const computedStates = liftedState.computedStates; + if ( + isFiltered(action, localFilter) || + (predicate && + !predicate(computedStates[computedStates.length - 1].state, action)) + ) { + return; + } + const state = + liftedState.computedStates[liftedState.computedStates.length - 1].state; + toContentScript( + { + type: 'ACTION', + payload: !stateSanitizer + ? state + : stateSanitizer(state, nextActionId - 1), + source, + instanceId, + action: !actionSanitizer + ? liftedState.actionsById[nextActionId - 1] + : actionSanitizer( + liftedState.actionsById[nextActionId - 1].action, + nextActionId - 1 + ), + maxAge: getMaxAge(), + nextActionId, + }, + serializeState, + serializeAction + ); + return; + } + + // Send multiple actions + const payload = startingFrom( + sendingActionId, + liftedState, + localFilter, + stateSanitizer, + actionSanitizer, + predicate + ); + sendingActionId = nextActionId; + if (typeof payload === 'undefined') return; + if ('skippedActionIds' in payload) { + toContentScript( + { + type: 'STATE', + payload: filterState( + payload, + localFilter, + stateSanitizer, + actionSanitizer, + predicate + ), + source, + instanceId, + }, + serializeState, + serializeAction + ); + return; + } + toContentScript( + { + type: 'PARTIAL_STATE', + payload, + source, + instanceId, + maxAge: getMaxAge(), + }, + serializeState, + serializeAction + ); + }, latency); + + function dispatchRemotely(action: string | CustomAction) { + if (config!.features && !config!.features.dispatch) return; + try { + const result = evalAction(action, actionCreators); + (store.initialDispatch || store.dispatch)(result); + } catch (e) { + toContentScript( + { + type: 'ERROR', + payload: e.message, + source, + instanceId, + }, + serializeState, + serializeAction + ); + } + } + + function importPayloadFrom(state: string | undefined) { + if (config!.features && !config!.features.import) return; + try { + const nextLiftedState = importState(state, config!); + if (!nextLiftedState) return; + store.liftedStore.dispatch({ type: 'IMPORT_STATE', ...nextLiftedState }); + } catch (e) { + toContentScript( + { + type: 'ERROR', + payload: e.message, + source, + instanceId, + }, + serializeState, + serializeAction + ); + } + } + + function dispatchMonitorAction(action: DispatchAction) { + const features = config!.features; + if (features) { + if ( + !features.jump && + (action.type === 'JUMP_TO_STATE' || action.type === 'JUMP_TO_ACTION') + ) { + return; + } + if (!features.skip && action.type === 'TOGGLE_ACTION') return; + if (!features.reorder && action.type === 'REORDER_ACTION') return; + if (!features.import && action.type === 'IMPORT_STATE') return; + if (!features.lock && action.type === 'LOCK_CHANGES') return; + if (!features.pause && action.type === 'PAUSE_RECORDING') return; + } + if (action.type === 'JUMP_TO_STATE') { + const liftedState = store.liftedStore.getState(); + const index = liftedState.stagedActionIds.indexOf(action.actionId); + if (index === -1) return; + store.liftedStore.dispatch({ type: action.type, index }); + return; + } + store.liftedStore.dispatch(action as any); + } + + function onMessage(message: ContentScriptToPageScriptMessage) { + switch (message.type) { + case 'DISPATCH': + dispatchMonitorAction(message.payload); + return; + case 'ACTION': + dispatchRemotely(message.payload); + return; + case 'IMPORT': + importPayloadFrom(message.state); + return; + case 'EXPORT': + exportState(); + return; + case 'UPDATE': + relayState(); + return; + case 'START': + monitor.start(true); + if (!actionCreators && config!.actionCreators) { + actionCreators = getActionsArray(config!.actionCreators); + } + relayState(undefined, { + name: config!.name || document.title, + actionCreators: JSON.stringify(actionCreators), + features: config!.features, + serialize: !!config!.serialize, + type: 'redux', + }); + + if (reportId) { + toContentScript( + { + type: 'GET_REPORT', + payload: reportId, + source, + instanceId, + }, + serializeState, + serializeAction + ); + reportId = null; + } + return; + case 'STOP': + monitor.stop(); + relayAction.cancel(); + relayState.cancel(); + if (!message.failed) { + toContentScript( + { + type: 'STOP', + payload: undefined, + source, + instanceId, + }, + serializeState, + serializeAction + ); + } + } + } + + const filteredActionIds: number[] = []; // simple circular buffer of non-excluded actions with fixed maxAge-1 length + const getMaxAge = ( + liftedAction?: LiftedAction, + liftedState?: LiftedState | undefined + ) => { + let m = (config && config.maxAge) || window.devToolsOptions.maxAge || 50; + if ( + !liftedAction || + noFiltersApplied(localFilter) || + !(liftedAction as PerformAction).action + ) { + return m; + } + if (!maxAge || maxAge < m) maxAge = m; // it can be modified in process on options page + if (isFiltered((liftedAction as PerformAction).action, localFilter)) { + // TODO: check also predicate && !predicate(state, action) with current state + maxAge++; + } else { + filteredActionIds.push(liftedState!.nextActionId); + if (filteredActionIds.length >= m) { + const stagedActionIds = liftedState!.stagedActionIds; + let i = 1; + while ( + maxAge > m && + filteredActionIds.indexOf(stagedActionIds[i]) === -1 + ) { + maxAge--; + i++; + } + filteredActionIds.shift(); + } + } + return maxAge; + }; + + function init() { + setListener(onMessage, instanceId); + notifyErrors(() => { + errorOccurred = true; + const state = store.liftedStore.getState(); + if (state.computedStates[state.currentStateIndex].error) { + relayState(state); + } + return true; + }); + + toContentScript( + { + type: 'INIT_INSTANCE', + payload: undefined, + source, + instanceId, + }, + serializeState, + serializeAction + ); + store.subscribe(handleChange); + + if (typeof reportId === 'undefined') { + reportId = getUrlParam('remotedev_report'); + if (reportId) openWindow(); + } + } + + function handleChange() { + if (!monitor.active) return; + if (!errorOccurred && !monitor.isMonitorAction()) { + relayAction(); + return; + } + if (monitor.isPaused() || monitor.isLocked() || monitor.isTimeTraveling()) { + return; + } + const liftedState = store.liftedStore.getState(); + if ( + errorOccurred && + !liftedState.computedStates[liftedState.currentStateIndex].error + ) { + errorOccurred = false; + } + relayState(liftedState); + } + + const enhance = + (): StoreEnhancer => + ( + next: StoreEnhancerStoreCreator + ): any => { + return ( + reducer_: Reducer, + initialState_?: PreloadedState + ) => { + if (!isAllowed(window.devToolsOptions)) { + return next(reducer_, initialState_); + } + + store = stores[instanceId] = configureStore(next, monitor.reducer, { + ...config, + maxAge: getMaxAge as any, + })(reducer_, initialState_) as any; + + if (isInIframe()) setTimeout(init, 3000); + else init(); + + return store; + }; + }; + + if (!reducer) return enhance(); + /* eslint-disable no-console */ + console.warn( + 'Creating a Redux store directly from DevTools extension is discouraged and will not be supported in future major version. For more details see: https://git.io/fphCe' + ); + /* eslint-enable no-console */ + return createStore(reducer, preloadedState, enhance); +} + +declare global { + interface Window { + __REDUX_DEVTOOLS_EXTENSION__: ReduxDevtoolsExtension; + } +} + +// noinspection JSAnnotator +window.__REDUX_DEVTOOLS_EXTENSION__ = __REDUX_DEVTOOLS_EXTENSION__ as any; +window.__REDUX_DEVTOOLS_EXTENSION__.open = openWindow; +window.__REDUX_DEVTOOLS_EXTENSION__.updateStore = updateStore(stores); +window.__REDUX_DEVTOOLS_EXTENSION__.notifyErrors = notifyErrors; +window.__REDUX_DEVTOOLS_EXTENSION__.send = sendMessage; +window.__REDUX_DEVTOOLS_EXTENSION__.listen = setListener; +window.__REDUX_DEVTOOLS_EXTENSION__.connect = connect; +window.__REDUX_DEVTOOLS_EXTENSION__.disconnect = disconnect; + +const preEnhancer = + (instanceId: number): StoreEnhancer => + (next) => + (reducer, preloadedState) => { + const store = next(reducer, preloadedState); + + if (stores[instanceId]) { + (stores[instanceId].initialDispatch as any) = store.dispatch; + } + + return { + ...store, + dispatch: (...args: any[]) => + !window.__REDUX_DEVTOOLS_EXTENSION_LOCKED__ && + (store.dispatch as any)(...args), + } as any; + }; + +const extensionCompose = + (config: Config) => + (...funcs: StoreEnhancer[]) => { + return (...args: any[]) => { + const instanceId = generateId(config.instanceId); + return [preEnhancer(instanceId), ...funcs].reduceRight( + (composed, f) => f(composed), + (__REDUX_DEVTOOLS_EXTENSION__({ ...config, instanceId }) as any)( + ...args + ) + ); + }; + }; + +declare global { + interface Window { + __REDUX_DEVTOOLS_EXTENSION_COMPOSE__: unknown; + } +} + +window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ = (...funcs: any[]) => { + if (funcs.length === 0) { + return __REDUX_DEVTOOLS_EXTENSION__(); + } + if (funcs.length === 1 && typeof funcs[0] === 'object') { + return extensionCompose(funcs[0]); + } + return extensionCompose({})(...funcs); +}; diff --git a/extension/src/browser/extension/inject/pageScriptWrap.js b/extension/src/browser/extension/inject/pageScriptWrap.ts similarity index 78% rename from extension/src/browser/extension/inject/pageScriptWrap.js rename to extension/src/browser/extension/inject/pageScriptWrap.ts index 0a8cef09..3c7f5147 100644 --- a/extension/src/browser/extension/inject/pageScriptWrap.js +++ b/extension/src/browser/extension/inject/pageScriptWrap.ts @@ -5,11 +5,13 @@ if (process.env.NODE_ENV === 'production') { const { default: script } = require('raw-loader!tmp/page.bundle.js'); s.appendChild(document.createTextNode(script)); (document.head || document.documentElement).appendChild(s); - s.parentNode.removeChild(s); + s.parentNode!.removeChild(s); } else { s.src = chrome.extension.getURL('page.bundle.js'); s.onload = function () { - this.parentNode.removeChild(this); + (this as HTMLScriptElement).parentNode!.removeChild( + this as HTMLScriptElement + ); }; (document.head || document.documentElement).appendChild(s); } diff --git a/extension/src/browser/extension/options/AllowToRunGroup.js b/extension/src/browser/extension/options/AllowToRunGroup.tsx similarity index 93% rename from extension/src/browser/extension/options/AllowToRunGroup.js rename to extension/src/browser/extension/options/AllowToRunGroup.tsx index d7c0cd82..f08990ae 100644 --- a/extension/src/browser/extension/options/AllowToRunGroup.js +++ b/extension/src/browser/extension/options/AllowToRunGroup.tsx @@ -1,6 +1,7 @@ import React from 'react'; +import { OptionsProps } from './Options'; -export default ({ options, saveOption }) => { +export default ({ options, saveOption }: OptionsProps) => { const AllowToRunState = { EVERYWHERE: true, ON_SPECIFIC_URLS: false, diff --git a/extension/src/browser/extension/options/ContextMenuGroup.js b/extension/src/browser/extension/options/ContextMenuGroup.tsx similarity index 87% rename from extension/src/browser/extension/options/ContextMenuGroup.js rename to extension/src/browser/extension/options/ContextMenuGroup.tsx index 8ceb80f8..727dda98 100644 --- a/extension/src/browser/extension/options/ContextMenuGroup.js +++ b/extension/src/browser/extension/options/ContextMenuGroup.tsx @@ -1,6 +1,7 @@ import React from 'react'; +import { OptionsProps } from './Options'; -export default ({ options, saveOption }) => { +export default ({ options, saveOption }: OptionsProps) => { return (
Context Menu diff --git a/extension/src/browser/extension/options/EditorGroup.js b/extension/src/browser/extension/options/EditorGroup.tsx similarity index 95% rename from extension/src/browser/extension/options/EditorGroup.js rename to extension/src/browser/extension/options/EditorGroup.tsx index 321c30b2..130ef824 100644 --- a/extension/src/browser/extension/options/EditorGroup.js +++ b/extension/src/browser/extension/options/EditorGroup.tsx @@ -1,6 +1,7 @@ import React from 'react'; +import { OptionsProps } from './Options'; -export default ({ options, saveOption }) => { +export default ({ options, saveOption }: OptionsProps) => { const EditorState = { BROWSER: 0, EXTERNAL: 1, @@ -45,7 +46,7 @@ export default ({ options, saveOption }) => { className="option__element" id="editor" type="text" - size="33" + size={33} maxLength={30} placeholder="vscode, atom, webstorm, sublime..." value={options.editor} diff --git a/extension/src/browser/extension/options/FilterGroup.js b/extension/src/browser/extension/options/FilterGroup.tsx similarity index 95% rename from extension/src/browser/extension/options/FilterGroup.js rename to extension/src/browser/extension/options/FilterGroup.tsx index 3567249f..4bc24536 100644 --- a/extension/src/browser/extension/options/FilterGroup.js +++ b/extension/src/browser/extension/options/FilterGroup.tsx @@ -1,7 +1,8 @@ import React from 'react'; import { FilterState } from '../../../app/api/filters'; +import { OptionsProps } from './Options'; -export default ({ options, saveOption }) => { +export default ({ options, saveOption }: OptionsProps) => { return (
diff --git a/extension/src/browser/extension/options/MiscellaneousGroup.js b/extension/src/browser/extension/options/MiscellaneousGroup.tsx similarity index 94% rename from extension/src/browser/extension/options/MiscellaneousGroup.js rename to extension/src/browser/extension/options/MiscellaneousGroup.tsx index c335c372..1c3c9a05 100644 --- a/extension/src/browser/extension/options/MiscellaneousGroup.js +++ b/extension/src/browser/extension/options/MiscellaneousGroup.tsx @@ -1,6 +1,7 @@ import React from 'react'; +import { OptionsProps } from './Options'; -export default ({ options, saveOption }) => { +export default ({ options, saveOption }: OptionsProps) => { const browserName = navigator.userAgent.includes('Firefox') ? 'Firefox' : 'Chrome'; diff --git a/extension/src/browser/extension/options/Options.js b/extension/src/browser/extension/options/Options.tsx similarity index 81% rename from extension/src/browser/extension/options/Options.js rename to extension/src/browser/extension/options/Options.tsx index 693a6f4a..579dd3ac 100644 --- a/extension/src/browser/extension/options/Options.js +++ b/extension/src/browser/extension/options/Options.tsx @@ -4,8 +4,17 @@ import FilterGroup from './FilterGroup'; import AllowToRunGroup from './AllowToRunGroup'; import MiscellaneousGroup from './MiscellaneousGroup'; import ContextMenuGroup from './ContextMenuGroup'; +import { Options } from './syncOptions'; -export default (props) => ( +export interface OptionsProps { + readonly options: Options; + readonly saveOption: ( + name: K, + value: Options[K] + ) => void; +} + +export default (props: OptionsProps) => (
diff --git a/extension/src/browser/extension/options/index.js b/extension/src/browser/extension/options/index.tsx similarity index 52% rename from extension/src/browser/extension/options/index.js rename to extension/src/browser/extension/options/index.tsx index 0dbc8719..55805985 100644 --- a/extension/src/browser/extension/options/index.js +++ b/extension/src/browser/extension/options/index.tsx @@ -1,19 +1,20 @@ import React from 'react'; import { render } from 'react-dom'; -import Options from './Options'; +import OptionsComponent from './Options'; +import { Options } from './syncOptions'; import '../../views/options.pug'; chrome.runtime.getBackgroundPage((background) => { - const syncOptions = background.syncOptions; + const syncOptions = background!.syncOptions; - const saveOption = (name, value) => { + const saveOption = (name: K, value: Options[K]) => { syncOptions.save(name, value); }; - const renderOptions = (options) => { + const renderOptions = (options: Options) => { render( - , + , document.getElementById('root') ); }; diff --git a/extension/src/browser/extension/options/syncOptions.js b/extension/src/browser/extension/options/syncOptions.js deleted file mode 100644 index 04827b5c..00000000 --- a/extension/src/browser/extension/options/syncOptions.js +++ /dev/null @@ -1,108 +0,0 @@ -import { FilterState } from '../../../app/api/filters'; - -let options; -let subscribers = []; - -const save = (toAllTabs) => (key, value) => { - let obj = {}; - obj[key] = value; - chrome.storage.sync.set(obj); - options[key] = value; - toAllTabs({ options: options }); - subscribers.forEach((s) => s(options)); -}; - -const migrateOldOptions = (oldOptions) => { - let newOptions = Object.assign({}, oldOptions); - - // Migrate the old `filter` option from 2.2.1 - if (typeof oldOptions.filter === 'boolean') { - if (oldOptions.filter && oldOptions.whitelist.length > 0) { - newOptions.filter = FilterState.WHITELIST_SPECIFIC; - } else if (oldOptions.filter) { - newOptions.filter = FilterState.BLACKLIST_SPECIFIC; - } else { - newOptions.filter = FilterState.DO_NOT_FILTER; - } - } - - return newOptions; -}; - -const get = (callback) => { - if (options) callback(options); - else { - chrome.storage.sync.get( - { - useEditor: 0, - editor: '', - projectPath: '', - maxAge: 50, - filter: FilterState.DO_NOT_FILTER, - whitelist: '', - blacklist: '', - shouldCatchErrors: false, - inject: true, - urls: '^https?://localhost|0\\.0\\.0\\.0:\\d+\n^https?://.+\\.github\\.io', - showContextMenus: true, - }, - function (items) { - options = migrateOldOptions(items); - callback(options); - } - ); - } -}; - -const subscribe = (callback) => { - subscribers = subscribers.concat(callback); -}; - -const toReg = (str) => - str !== '' ? str.split('\n').filter(Boolean).join('|') : null; - -export const injectOptions = (newOptions) => { - if (!newOptions) return; - if (newOptions.filter !== FilterState.DO_NOT_FILTER) { - newOptions.whitelist = toReg(newOptions.whitelist); - newOptions.blacklist = toReg(newOptions.blacklist); - } - - options = newOptions; - let s = document.createElement('script'); - s.type = 'text/javascript'; - s.appendChild( - document.createTextNode( - 'window.devToolsOptions = Object.assign(window.devToolsOptions||{},' + - JSON.stringify(options) + - ');' - ) - ); - (document.head || document.documentElement).appendChild(s); - s.parentNode.removeChild(s); -}; - -export const getOptionsFromBg = () => { - /* chrome.runtime.sendMessage({ type: 'GET_OPTIONS' }, response => { - if (response && response.options) injectOptions(response.options); - }); -*/ - get((newOptions) => { - injectOptions(newOptions); - }); // Legacy -}; - -export const isAllowed = (localOptions = options) => - !localOptions || - localOptions.inject || - !localOptions.urls || - location.href.match(toReg(localOptions.urls)); - -export default function syncOptions(toAllTabs) { - if (toAllTabs && !options) get(() => {}); // Initialize - return { - save: save(toAllTabs), - get: get, - subscribe: subscribe, - }; -} diff --git a/extension/src/browser/extension/options/syncOptions.ts b/extension/src/browser/extension/options/syncOptions.ts new file mode 100644 index 00000000..2164fee1 --- /dev/null +++ b/extension/src/browser/extension/options/syncOptions.ts @@ -0,0 +1,152 @@ +import { FilterState, FilterStateValue } from '../../../app/api/filters'; + +export interface Options { + readonly useEditor: number; + readonly editor: string; + readonly projectPath: string; + readonly maxAge: number; + readonly filter: FilterStateValue; + readonly whitelist: string; + readonly blacklist: string; + readonly shouldCatchErrors: boolean; + readonly inject: boolean; + readonly urls: string; + readonly showContextMenus: boolean; +} + +interface OldOrNewOptions { + readonly useEditor: number; + readonly editor: string; + readonly projectPath: string; + readonly maxAge: number; + readonly filter: FilterStateValue | boolean; + readonly whitelist: string; + readonly blacklist: string; + readonly shouldCatchErrors: boolean; + readonly inject: boolean; + readonly urls: string; + readonly showContextMenus: boolean; +} + +let options: Options | undefined; +let subscribers: ((options: Options) => void)[] = []; + +export interface OptionsMessage { + readonly options: Options; +} + +type ToAllTabs = (msg: OptionsMessage) => void; + +const save = + (toAllTabs: ToAllTabs | undefined) => + (key: K, value: Options[K]) => { + let obj: { [K1 in keyof Options]?: Options[K1] } = {}; + obj[key] = value; + chrome.storage.sync.set(obj); + options![key] = value; + toAllTabs!({ options: options! }); + subscribers.forEach((s) => s(options!)); + }; + +const migrateOldOptions = (oldOptions: OldOrNewOptions): Options => ({ + ...oldOptions, + filter: + // Migrate the old `filter` option from 2.2.1 + typeof oldOptions.filter === 'boolean' + ? oldOptions.filter && oldOptions.whitelist.length > 0 + ? FilterState.WHITELIST_SPECIFIC + : oldOptions.filter + ? FilterState.BLACKLIST_SPECIFIC + : FilterState.DO_NOT_FILTER + : oldOptions.filter, +}); + +const get = (callback: (options: Options) => void) => { + if (options) callback(options); + else { + chrome.storage.sync.get( + { + useEditor: 0, + editor: '', + projectPath: '', + maxAge: 50, + filter: FilterState.DO_NOT_FILTER, + whitelist: '', + blacklist: '', + shouldCatchErrors: false, + inject: true, + urls: '^https?://localhost|0\\.0\\.0\\.0:\\d+\n^https?://.+\\.github\\.io', + showContextMenus: true, + }, + function (items) { + options = migrateOldOptions(items as OldOrNewOptions); + callback(options); + } + ); + } +}; + +const subscribe = (callback: (options: Options) => void) => { + subscribers = subscribers.concat(callback); +}; + +const toReg = (str: string) => + str !== '' ? str.split('\n').filter(Boolean).join('|') : null; + +export const injectOptions = (newOptions: Options) => { + if (!newOptions) return; + + options = { + ...newOptions, + whitelist: + newOptions.filter !== FilterState.DO_NOT_FILTER + ? toReg(newOptions.whitelist)! + : newOptions.whitelist, + blacklist: + newOptions.filter !== FilterState.DO_NOT_FILTER + ? toReg(newOptions.blacklist)! + : newOptions.blacklist, + }; + let s = document.createElement('script'); + s.type = 'text/javascript'; + s.appendChild( + document.createTextNode( + 'window.devToolsOptions = Object.assign(window.devToolsOptions||{},' + + JSON.stringify(options) + + ');' + ) + ); + (document.head || document.documentElement).appendChild(s); + s.parentNode!.removeChild(s); +}; + +export const getOptionsFromBg = () => { + /* chrome.runtime.sendMessage({ type: 'GET_OPTIONS' }, response => { + if (response && response.options) injectOptions(response.options); + }); +*/ + get((newOptions) => { + injectOptions(newOptions); + }); // Legacy +}; + +export const isAllowed = (localOptions = options) => + !localOptions || + localOptions.inject || + !localOptions.urls || + location.href.match(toReg(localOptions.urls)!); + +export interface SyncOptions { + readonly save: (key: K, value: Options[K]) => void; + readonly get: (callback: (options: Options) => void) => void; + readonly subscribe: (callback: (options: Options) => void) => void; +} + +export default function syncOptions(toAllTabs?: ToAllTabs): SyncOptions { + if (toAllTabs && !options) get(() => {}); // Initialize + return { + save: save(toAllTabs), + get: get, + subscribe: subscribe, + }; +} diff --git a/extension/src/browser/extension/window/index.js b/extension/src/browser/extension/window/index.tsx similarity index 76% rename from extension/src/browser/extension/window/index.js rename to extension/src/browser/extension/window/index.tsx index e1f1260f..d583cbbf 100644 --- a/extension/src/browser/extension/window/index.js +++ b/extension/src/browser/extension/window/index.tsx @@ -1,27 +1,31 @@ import React from 'react'; import { render } from 'react-dom'; +import { PreloadedState } from 'redux'; import { Provider } from 'react-redux'; import { UPDATE_STATE } from '@redux-devtools/app/lib/constants/actionTypes'; +import { StoreState } from '@redux-devtools/app/lib/reducers'; import App from '../../../app/containers/App'; import configureStore from '../../../app/stores/windowStore'; import getPreloadedState from '../background/getPreloadedState'; import '../../views/window.pug'; +import { MonitorMessage } from '../../../app/middlewares/api'; const position = location.hash; -let preloadedState; +let preloadedState: PreloadedState; getPreloadedState(position, (state) => { preloadedState = state; }); -chrome.runtime.getBackgroundPage(({ store }) => { +chrome.runtime.getBackgroundPage((window) => { + const { store } = window!; const localStore = configureStore(store, position, preloadedState); let name = 'monitor'; if (chrome && chrome.devtools && chrome.devtools.inspectedWindow) { name += chrome.devtools.inspectedWindow.tabId; } const bg = chrome.runtime.connect({ name }); - const update = (action) => { + const update = (action?: MonitorMessage) => { localStore.dispatch(action || { type: UPDATE_STATE }); }; bg.onMessage.addListener(update); diff --git a/extension/src/browser/extension/window/remote.js b/extension/src/browser/extension/window/remote.tsx similarity index 93% rename from extension/src/browser/extension/window/remote.js rename to extension/src/browser/extension/window/remote.tsx index e4e271e6..a2ec9e86 100644 --- a/extension/src/browser/extension/window/remote.js +++ b/extension/src/browser/extension/window/remote.tsx @@ -14,12 +14,12 @@ chrome.storage.local.get( 's:secure': null, }, (options) => { + const AppAsAny = App as any; render( - ({ // devtool: 'source-map', @@ -64,7 +64,7 @@ const baseConfig = (params) => ({ app: path.join(__dirname, '../src/app'), tmp: path.join(__dirname, '../build/tmp'), }, - extensions: ['.js'], + extensions: ['.js', '.jsx', '.ts', '.tsx'], }, module: { rules: [ @@ -72,7 +72,7 @@ const baseConfig = (params) => ({ ? params.loaders : [ { - test: /\.js$/, + test: /\.(js|ts)x?$/, use: 'babel-loader', exclude: /(node_modules|tmp\/page\.bundle)/, }, diff --git a/packages/redux-devtools-app/src/actions/index.ts b/packages/redux-devtools-app/src/actions/index.ts index 3eda8bb8..362834b6 100644 --- a/packages/redux-devtools-app/src/actions/index.ts +++ b/packages/redux-devtools-app/src/actions/index.ts @@ -45,6 +45,7 @@ import { Features, State } from '../reducers/instances'; import { MonitorStateMonitorState } from '../reducers/monitor'; import { LiftedAction } from '@redux-devtools/core'; import { Data } from '../reducers/reports'; +import { LiftedState } from '@redux-devtools/instrument'; let monitorReducer: ( monitorProps: unknown, @@ -53,7 +54,7 @@ let monitorReducer: ( ) => unknown; let monitorProps: unknown = {}; -interface ChangeSectionAction { +export interface ChangeSectionAction { readonly type: typeof CHANGE_SECTION; readonly section: string; } @@ -69,7 +70,7 @@ interface ChangeThemeFormData { interface ChangeThemeData { readonly formData: ChangeThemeFormData; } -interface ChangeThemeAction { +export interface ChangeThemeAction { readonly type: typeof CHANGE_THEME; readonly theme: Theme; readonly scheme: Scheme; @@ -119,13 +120,28 @@ export interface LockChangesAction { } export interface ToggleActionAction { type: 'TOGGLE_ACTION'; + id: number; } export interface RollbackAction { type: 'ROLLBACK'; + timestamp: number; } export interface SweepAction { type: 'SWEEP'; } +interface ReorderActionAction { + type: 'REORDER_ACTION'; + actionId: number; + beforeActionId: number; +} +interface ImportStateAction { + type: 'IMPORT_STATE'; + nextLiftedState: + | LiftedState, unknown> + | readonly Action[]; + preloadedState?: unknown; + noRecompute?: boolean | undefined; +} export type DispatchAction = | JumpToStateAction | JumpToActionAction @@ -133,7 +149,9 @@ export type DispatchAction = | LockChangesAction | ToggleActionAction | RollbackAction - | SweepAction; + | SweepAction + | ReorderActionAction + | ImportStateAction; interface LiftedActionActionBase { action?: DispatchAction | string | CustomAction; state?: string; @@ -145,18 +163,18 @@ export interface LiftedActionDispatchAction extends LiftedActionActionBase { action: DispatchAction; toAll?: boolean; } -interface LiftedActionImportAction extends LiftedActionActionBase { +export interface LiftedActionImportAction extends LiftedActionActionBase { type: typeof LIFTED_ACTION; message: 'IMPORT'; state: string; preloadedState: unknown | undefined; } -interface LiftedActionActionAction extends LiftedActionActionBase { +export interface LiftedActionActionAction extends LiftedActionActionBase { type: typeof LIFTED_ACTION; message: 'ACTION'; action: string | CustomAction; } -interface LiftedActionExportAction extends LiftedActionActionBase { +export interface LiftedActionExportAction extends LiftedActionActionBase { type: typeof LIFTED_ACTION; message: 'EXPORT'; toExport: boolean; @@ -192,15 +210,15 @@ export function liftedDispatch( } as LiftedActionDispatchAction; } -interface SelectInstanceAction { +export interface SelectInstanceAction { type: typeof SELECT_INSTANCE; - selected: string; + selected: string | number; } export function selectInstance(selected: string): SelectInstanceAction { return { type: SELECT_INSTANCE, selected }; } -interface SelectMonitorAction { +export interface SelectMonitorAction { type: typeof SELECT_MONITOR; monitor: string; monitorState?: MonitorStateMonitorState; @@ -219,7 +237,7 @@ interface NextState { subTabName: string; inspectedStatePath?: string[]; } -interface UpdateMonitorStateAction { +export interface UpdateMonitorStateAction { type: typeof UPDATE_MONITOR_STATE; nextState: NextState; } @@ -240,7 +258,7 @@ export function importState( return { type: LIFTED_ACTION, message: 'IMPORT', state, preloadedState }; } -interface ExportAction { +export interface ExportAction { type: typeof EXPORT; } export function exportState(): ExportAction { @@ -268,7 +286,7 @@ export function pauseRecording(status: boolean): LiftedActionDispatchAction { export interface CustomAction { name: string; selected: number; - args: (string | undefined)[]; + args: string[]; rest: string; } export function dispatchRemotely( @@ -277,28 +295,28 @@ export function dispatchRemotely( return { type: LIFTED_ACTION, message: 'ACTION', action }; } -interface TogglePersistAction { +export interface TogglePersistAction { type: typeof TOGGLE_PERSIST; } export function togglePersist(): TogglePersistAction { return { type: TOGGLE_PERSIST }; } -interface ToggleSyncAction { +export interface ToggleSyncAction { type: typeof TOGGLE_SYNC; } export function toggleSync(): ToggleSyncAction { return { type: TOGGLE_SYNC }; } -interface ToggleSliderAction { +export interface ToggleSliderAction { type: typeof TOGGLE_SLIDER; } export function toggleSlider(): ToggleSliderAction { return { type: TOGGLE_SLIDER }; } -interface ToggleDispatcherAction { +export interface ToggleDispatcherAction { type: typeof TOGGLE_DISPATCHER; } export function toggleDispatcher(): ToggleDispatcherAction { @@ -312,7 +330,7 @@ export interface ConnectionOptions { readonly port: number; readonly secure: boolean; } -interface ReconnectAction { +export interface ReconnectAction { readonly type: typeof RECONNECT; readonly options: ConnectionOptions; } @@ -326,7 +344,7 @@ interface Notification { readonly type: 'error'; readonly message: string; } -interface ShowNotificationAction { +export interface ShowNotificationAction { readonly type: typeof SHOW_NOTIFICATION; readonly notification: Notification; } @@ -334,14 +352,14 @@ export function showNotification(message: string): ShowNotificationAction { return { type: SHOW_NOTIFICATION, notification: { type: 'error', message } }; } -interface ClearNotificationAction { +export interface ClearNotificationAction { readonly type: typeof CLEAR_NOTIFICATION; } export function clearNotification(): ClearNotificationAction { return { type: CLEAR_NOTIFICATION }; } -interface GetReportRequest { +export interface GetReportRequest { readonly type: typeof GET_REPORT_REQUEST; readonly report: unknown; } @@ -354,7 +372,7 @@ export interface ActionCreator { name: string; } -interface LibConfig { +export interface LibConfig { actionCreators?: string; name?: string; type?: string; @@ -363,10 +381,10 @@ interface LibConfig { } export interface RequestBase { - id: string; - instanceId?: string; + id?: string; + instanceId?: string | number; action?: string; - name?: string; + name?: string | undefined; libConfig?: LibConfig; actionsById?: string; computedStates?: string; @@ -376,14 +394,15 @@ export interface RequestBase { } interface InitRequest extends RequestBase { type: 'INIT'; - action: string; + action?: string; + payload?: string; } interface ActionRequest extends RequestBase { type: 'ACTION'; - isExcess: boolean; + isExcess?: boolean; nextActionId: number; maxAge: number; - batched: boolean; + batched?: boolean; } interface StateRequest extends RequestBase { type: 'STATE'; @@ -409,23 +428,23 @@ export type Request = | LiftedRequest | ExportRequest; -interface UpdateStateAction { +export interface UpdateStateAction { type: typeof UPDATE_STATE; request?: Request; - id?: string; + id?: string | number; } -interface SetStateAction { +export interface SetStateAction { type: typeof SET_STATE; newState: State; } -interface RemoveInstanceAction { +export interface RemoveInstanceAction { type: typeof REMOVE_INSTANCE; - id: string; + id: string | number; } -interface ConnectRequestAction { +export interface ConnectRequestAction { type: typeof CONNECT_REQUEST; options: ConnectionOptions; } @@ -435,58 +454,58 @@ interface ConnectSuccessPayload { authState: AuthStates; socketState: States; } -interface ConnectSuccessAction { +export interface ConnectSuccessAction { type: typeof CONNECT_SUCCESS; payload: ConnectSuccessPayload; error: Error | undefined; } -interface ConnectErrorAction { +export interface ConnectErrorAction { type: typeof CONNECT_ERROR; error: Error | undefined; } -interface AuthRequestAction { +export interface AuthRequestAction { type: typeof AUTH_REQUEST; } -interface AuthSuccessAction { +export interface AuthSuccessAction { type: typeof AUTH_SUCCESS; baseChannel: string; } -interface AuthErrorAction { +export interface AuthErrorAction { type: typeof AUTH_ERROR; error: Error; } -interface DisconnectedAction { +export interface DisconnectedAction { type: typeof DISCONNECTED; code: number; } -interface DeauthenticateAction { +export interface DeauthenticateAction { type: typeof DEAUTHENTICATE; } -interface SubscribeRequestAction { +export interface SubscribeRequestAction { type: typeof SUBSCRIBE_REQUEST; channel: string; subscription: typeof UPDATE_STATE | typeof UPDATE_REPORTS; } -interface SubscribeSuccessAction { +export interface SubscribeSuccessAction { type: typeof SUBSCRIBE_SUCCESS; channel: string; } -interface SubscribeErrorAction { +export interface SubscribeErrorAction { type: typeof SUBSCRIBE_ERROR; error: Error; status: string; } -interface UnsubscribeAction { +export interface UnsubscribeAction { type: typeof UNSUBSCRIBE; channel: string; } @@ -494,8 +513,8 @@ interface UnsubscribeAction { export interface EmitAction { type: typeof EMIT; message: string; - id?: string | false; - instanceId?: string; + id?: string | number | false; + instanceId?: string | number; action?: unknown; state?: unknown; } @@ -514,31 +533,30 @@ interface RemoveRequest { id: unknown; } export type UpdateReportsRequest = ListRequest | AddRequest | RemoveRequest; -interface UpdateReportsAction { +export interface UpdateReportsAction { type: typeof UPDATE_REPORTS; request: UpdateReportsRequest; } -interface GetReportError { +export interface GetReportError { type: typeof GET_REPORT_ERROR; error: Error; } -interface GetReportSuccess { +export interface GetReportSuccess { type: typeof GET_REPORT_SUCCESS; data: { payload: string }; } -interface ErrorAction { +export interface ErrorAction { type: typeof ERROR; payload: string; } -export type StoreAction = +export type StoreActionWithoutUpdateStateOrLiftedAction = | ChangeSectionAction | ChangeThemeAction | MonitorActionAction - | LiftedActionAction | SelectInstanceAction | SelectMonitorAction | UpdateMonitorStateAction @@ -552,7 +570,6 @@ export type StoreAction = | ClearNotificationAction | GetReportRequest | SetStateAction - | UpdateStateAction | RemoveInstanceAction | ConnectRequestAction | ConnectSuccessAction @@ -571,3 +588,13 @@ export type StoreAction = | GetReportError | GetReportSuccess | ErrorAction; + +export type StoreActionWithoutUpdateState = + | StoreActionWithoutUpdateStateOrLiftedAction + | LiftedActionAction; + +export type StoreActionWithoutLiftedAction = + | StoreActionWithoutUpdateStateOrLiftedAction + | UpdateStateAction; + +export type StoreAction = StoreActionWithoutUpdateState | UpdateStateAction; diff --git a/packages/redux-devtools-app/src/components/InstanceSelector.tsx b/packages/redux-devtools-app/src/components/InstanceSelector.tsx index 357351b7..81680a38 100644 --- a/packages/redux-devtools-app/src/components/InstanceSelector.tsx +++ b/packages/redux-devtools-app/src/components/InstanceSelector.tsx @@ -9,7 +9,7 @@ type DispatchProps = ResolveThunks; type Props = StateProps & DispatchProps; class InstanceSelector extends Component { - select?: { readonly value: string; readonly label: string }[]; + select?: { readonly value: string; readonly label: string | number }[]; render() { this.select = [{ value: '', label: 'Autoselect instances' }]; diff --git a/packages/redux-devtools-app/src/containers/monitors/Dispatcher.tsx b/packages/redux-devtools-app/src/containers/monitors/Dispatcher.tsx index 63ed71e5..4a6a1746 100644 --- a/packages/redux-devtools-app/src/containers/monitors/Dispatcher.tsx +++ b/packages/redux-devtools-app/src/containers/monitors/Dispatcher.tsx @@ -55,7 +55,7 @@ type Props = DispatchProps & OwnProps; interface State { selected: 'default' | number; customAction: string; - args: (string | undefined)[]; + args: string[]; rest: string; changed: boolean; } @@ -108,7 +108,7 @@ class Dispatcher extends Component { handleArg = (argIndex: number) => (value: string) => { const args = [ ...this.state.args.slice(0, argIndex), - value || undefined, + (value || undefined)!, ...this.state.args.slice(argIndex + 1), ]; this.setState({ args, changed: true }); diff --git a/packages/redux-devtools-app/src/middlewares/api.ts b/packages/redux-devtools-app/src/middlewares/api.ts index 2b135369..f5c29ae1 100644 --- a/packages/redux-devtools-app/src/middlewares/api.ts +++ b/packages/redux-devtools-app/src/middlewares/api.ts @@ -30,7 +30,7 @@ let socket: SCClientSocket; let store: MiddlewareAPI, StoreState>; function emit({ message: type, id, instanceId, action, state }: EmitAction) { - socket.emit(id ? 'sc-' + id : 'respond', { type, action, state, instanceId }); + socket.emit(id ? `sc-${id}` : 'respond', { type, action, state, instanceId }); } function startMonitoring(channel: string) { diff --git a/packages/redux-devtools-app/src/middlewares/exportState.ts b/packages/redux-devtools-app/src/middlewares/exportState.ts index 40e9cd0a..ffefe851 100644 --- a/packages/redux-devtools-app/src/middlewares/exportState.ts +++ b/packages/redux-devtools-app/src/middlewares/exportState.ts @@ -5,7 +5,7 @@ import { Dispatch, MiddlewareAPI } from 'redux'; import { ExportRequest, StoreAction } from '../actions'; import { StoreState } from '../reducers'; -let toExport: string | undefined; +let toExport: string | number | undefined; function download(state: string) { const blob = new Blob([state], { type: 'octet/stream' }); diff --git a/packages/redux-devtools-app/src/reducers/instances.ts b/packages/redux-devtools-app/src/reducers/instances.ts index 9b2aa01e..cbc3fc00 100644 --- a/packages/redux-devtools-app/src/reducers/instances.ts +++ b/packages/redux-devtools-app/src/reducers/instances.ts @@ -34,8 +34,8 @@ export interface Features { } export interface Options { - name?: string; - connectionId?: string; + name?: string | number; + connectionId?: string | number; explicitLib?: string; lib?: string; actionCreators?: ActionCreator[]; @@ -56,10 +56,10 @@ export interface State { } export interface InstancesState { - selected: string | null; - current: string; + selected: string | number | null; + current: string | number; sync: boolean; - connections: { [id: string]: string[] }; + connections: { [id: string]: (string | number)[] }; options: { [id: string]: Options }; states: { [id: string]: State }; persisted?: boolean; @@ -86,7 +86,7 @@ export const initialState: InstancesState = { function updateState( state: { [id: string]: State }, request: Request, - id: string, + id: string | number, serialize: boolean | undefined ) { let payload: State = request.payload as State; @@ -231,7 +231,7 @@ export function dispatchAction( return state; } -function removeState(state: InstancesState, connectionId: string) { +function removeState(state: InstancesState, connectionId: string | number) { const instanceIds = state.connections[connectionId]; if (!instanceIds) return state; @@ -268,8 +268,8 @@ function removeState(state: InstancesState, connectionId: string) { function init( { type, action, name, libConfig = {} }: Request, - connectionId: string, - current: string + connectionId: string | number, + current: string | number ): Options { let lib; let actionCreators; @@ -310,7 +310,7 @@ export default function instances( case UPDATE_STATE: { const { request } = action; if (!request) return state; - const connectionId = action.id || request.id; + const connectionId = (action.id || request.id)!; const current = request.instanceId || connectionId; let connections = state.connections; let options = state.options; diff --git a/packages/redux-devtools-app/src/reducers/monitor.ts b/packages/redux-devtools-app/src/reducers/monitor.ts index 8293d847..be478fb1 100644 --- a/packages/redux-devtools-app/src/reducers/monitor.ts +++ b/packages/redux-devtools-app/src/reducers/monitor.ts @@ -18,7 +18,7 @@ export interface MonitorStateMonitorState { } export interface MonitorState { selected: string; - monitorState: MonitorStateMonitorState | undefined; + monitorState?: MonitorStateMonitorState | undefined; sliderIsOpen: boolean; dispatcherIsOpen: boolean; } diff --git a/packages/redux-devtools-app/src/utils/monitorActions.ts b/packages/redux-devtools-app/src/utils/monitorActions.ts index 1e5fc604..d2832bd9 100644 --- a/packages/redux-devtools-app/src/utils/monitorActions.ts +++ b/packages/redux-devtools-app/src/utils/monitorActions.ts @@ -4,8 +4,7 @@ import stringifyJSON from './stringifyJSON'; import { SET_STATE } from '../constants/actionTypes'; import { InstancesState, State } from '../reducers/instances'; import { Dispatch, MiddlewareAPI } from 'redux'; -import { DispatchAction, StoreAction } from '../actions'; -import { StoreState } from '../reducers'; +import { DispatchAction, StoreActionWithoutLiftedAction } from '../actions'; export function sweep(state: State): State { return { @@ -21,12 +20,15 @@ export function sweep(state: State): State { } export function nonReduxDispatch( - store: MiddlewareAPI, StoreState>, + store: MiddlewareAPI< + Dispatch, + { readonly instances: InstancesState } + >, message: string, - instanceId: string, + instanceId: string | number, action: DispatchAction, initialState: string | undefined, - preInstances: InstancesState + preInstances?: InstancesState ) { const instances = preInstances || store.getState().instances; const state = instances.states[instanceId]; diff --git a/packages/redux-devtools-inspector-monitor-test-tab/package.json b/packages/redux-devtools-inspector-monitor-test-tab/package.json index 4de697fe..6d529f82 100644 --- a/packages/redux-devtools-inspector-monitor-test-tab/package.json +++ b/packages/redux-devtools-inspector-monitor-test-tab/package.json @@ -59,7 +59,7 @@ "@redux-devtools/inspector-monitor": "^1.0.0", "@types/es6template": "^1.0.0", "@types/history": "^4.7.8", - "@types/jsan": "^3.1.0", + "@types/jsan": "^3.1.2", "@types/lodash.shuffle": "^4.2.6", "@types/object-path": "^0.11.0", "@types/react": "^16.14.8", diff --git a/packages/redux-devtools-inspector-monitor-trace-tab/package.json b/packages/redux-devtools-inspector-monitor-trace-tab/package.json index 9a1efda3..299d2ec2 100644 --- a/packages/redux-devtools-inspector-monitor-trace-tab/package.json +++ b/packages/redux-devtools-inspector-monitor-trace-tab/package.json @@ -29,7 +29,7 @@ }, "dependencies": { "@babel/code-frame": "^7.14.5", - "@types/chrome": "^0.0.124", + "@types/chrome": "^0.0.145", "anser": "^1.4.10", "html-entities": "^1.4.0", "redux-devtools-themes": "^1.0.0", diff --git a/packages/redux-devtools-inspector-monitor-trace-tab/src/openFile.ts b/packages/redux-devtools-inspector-monitor-trace-tab/src/openFile.ts index aa8e22f5..a542fa40 100644 --- a/packages/redux-devtools-inspector-monitor-trace-tab/src/openFile.ts +++ b/packages/redux-devtools-inspector-monitor-trace-tab/src/openFile.ts @@ -32,11 +32,11 @@ function openAndCloseTab(url: string) { const removeTab = () => { chrome.windows.onFocusChanged.removeListener(removeTab); if (tab && tab.id) { - chrome.tabs.remove(tab.id, () => { + chrome.tabs.remove(tab.id, async () => { // eslint-disable-next-line no-console if (chrome.runtime.lastError) console.log(chrome.runtime.lastError); else if (chrome.devtools && chrome.devtools.inspectedWindow) { - chrome.tabs.update(chrome.devtools.inspectedWindow.tabId, { + await chrome.tabs.update(chrome.devtools.inspectedWindow.tabId, { active: true, }); } diff --git a/packages/redux-devtools-instrument/src/instrument.ts b/packages/redux-devtools-instrument/src/instrument.ts index 4f6d35aa..91a7772f 100644 --- a/packages/redux-devtools-instrument/src/instrument.ts +++ b/packages/redux-devtools-instrument/src/instrument.ts @@ -100,7 +100,7 @@ interface ImportStateAction, MonitorState> { type: typeof ActionTypes.IMPORT_STATE; nextLiftedState: LiftedState | readonly A[]; preloadedState?: S; - noRecompute: boolean | undefined; + noRecompute?: boolean | undefined; } interface LockChangesAction { diff --git a/packages/redux-devtools-serialize/package.json b/packages/redux-devtools-serialize/package.json index ebca64f5..84d9120b 100644 --- a/packages/redux-devtools-serialize/package.json +++ b/packages/redux-devtools-serialize/package.json @@ -35,7 +35,7 @@ "jsan": "^3.1.13" }, "devDependencies": { - "@types/jsan": "^3.1.0", + "@types/jsan": "^3.1.2", "immutable": "^4.0.0-rc.12" }, "peerDependencies": { diff --git a/packages/redux-devtools-utils/src/index.ts b/packages/redux-devtools-utils/src/index.ts index fd4ab9fc..b4af6b8a 100644 --- a/packages/redux-devtools-utils/src/index.ts +++ b/packages/redux-devtools-utils/src/index.ts @@ -3,22 +3,24 @@ import jsan from 'jsan'; import { nanoid } from 'nanoid/non-secure'; import { immutableSerialize } from '@redux-devtools/serialize'; import Immutable from 'immutable'; -import { Action } from 'redux'; +import { Action, ActionCreator } from 'redux'; export function generateId(id: string | undefined) { return id || nanoid(7); } +export interface ActionCreatorObject { + readonly name: string; + readonly func: ActionCreator>; + readonly args: readonly string[]; +} + // eslint-disable-next-line @typescript-eslint/ban-types function flatTree( - obj: { [key: string]: (...args: any[]) => unknown }, + obj: { [key: string]: ActionCreator> }, namespace = '' ) { - let functions: { - name: string; - func: (...args: any[]) => unknown; - args: string[]; - }[] = []; + let functions: ActionCreatorObject[] = []; Object.keys(obj).forEach((key) => { const prop = obj[key]; if (typeof prop === 'function') { @@ -63,7 +65,7 @@ export function getMethods(obj: unknown) { } export function getActionsArray(actionCreators: { - [key: string]: (...args: any[]) => unknown; + [key: string]: ActionCreator>; }) { if (Array.isArray(actionCreators)) return actionCreators; return flatTree(actionCreators); @@ -81,10 +83,8 @@ function evalArgs(inArgs: string[], restArgs: string) { } export function evalAction( - action: string | { args: string[]; rest: string; selected: string }, - actionCreators: { - [selected: string]: { func: (...args: any[]) => Action }; - } + action: string | { args: string[]; rest: string; selected: number }, + actionCreators: readonly ActionCreatorObject[] ) { if (typeof action === 'string') { // eslint-disable-next-line @typescript-eslint/no-implied-eval diff --git a/yarn.lock b/yarn.lock index 90a9dbce..83bb3385 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3520,7 +3520,7 @@ __metadata: "@redux-devtools/inspector-monitor": ^1.0.0 "@types/es6template": ^1.0.0 "@types/history": ^4.7.8 - "@types/jsan": ^3.1.0 + "@types/jsan": ^3.1.2 "@types/lodash.shuffle": ^4.2.6 "@types/object-path": ^0.11.0 "@types/prop-types": ^15.7.3 @@ -3567,7 +3567,7 @@ __metadata: "@redux-devtools/core": ^3.9.0 "@redux-devtools/inspector-monitor": ^1.0.0 "@types/babel__code-frame": ^7.0.2 - "@types/chrome": ^0.0.124 + "@types/chrome": ^0.0.145 "@types/enzyme": ^3.10.8 "@types/enzyme-adapter-react-16": ^1.0.6 "@types/html-entities": ^1.3.4 @@ -3682,7 +3682,7 @@ __metadata: version: 0.0.0-use.local resolution: "@redux-devtools/serialize@workspace:packages/redux-devtools-serialize" dependencies: - "@types/jsan": ^3.1.0 + "@types/jsan": ^3.1.2 immutable: ^4.0.0-rc.12 jsan: ^3.1.13 peerDependencies: @@ -4825,13 +4825,13 @@ __metadata: languageName: node linkType: hard -"@types/chrome@npm:^0.0.124": - version: 0.0.124 - resolution: "@types/chrome@npm:0.0.124" +"@types/chrome@npm:^0.0.145": + version: 0.0.145 + resolution: "@types/chrome@npm:0.0.145" dependencies: "@types/filesystem": "*" "@types/har-format": "*" - checksum: 6499edca5f608dd48651b20d57d9fb30bbcb02cd695cd94d879a11dba7b9492c618edc6b8b5f718e82b58eceea94fb920c871546c2c3bc867595cb7dd020d527 + checksum: f826d0a071ac7ea68aa97b2f8e34a944c470fbd036fdd6a413987fea03062d354fb14708cc0b59693b4a78ec88f9c5b1fce0c622a8e29e3e4b0917509eaf2a1a languageName: node linkType: hard @@ -5246,10 +5246,10 @@ __metadata: languageName: node linkType: hard -"@types/jsan@npm:^3.1.0": - version: 3.1.0 - resolution: "@types/jsan@npm:3.1.0" - checksum: a0670d90e4bee7110504be73eefff9196b46235faf490062865136b1cbad4d3bac2adb9303e308c523f07716c026bb8e72a12ae7d47a948424d4ca4d7883587d +"@types/jsan@npm:^3.1.2": + version: 3.1.2 + resolution: "@types/jsan@npm:3.1.2" + checksum: 2ff652807d6067bbc650aaefcda4e3c07b54ddfd7d72283d7c1f1892ad1e18e907b1bbdbee7d0a163efa9e8aed9af5fa9f4ed8e2f27243c46383d31e1181fc11 languageName: node linkType: hard @@ -23231,6 +23231,7 @@ fsevents@^1.2.7: "@redux-devtools/serialize": ^0.3.0 "@redux-devtools/slider-monitor": ^2.0.0-8 "@redux-devtools/utils": ^1.0.0-6 + "@types/jsan": ^3.1.2 bestzip: ^2.2.0 chromedriver: ^91.0.1 electron: ^13.1.2