import { CustomAction, DispatchAction as AppDispatchAction, LibConfig, LIFTED_ACTION, nonReduxDispatch, REMOVE_INSTANCE, SET_PERSIST, SetPersistAction, stringifyJSON, TOGGLE_PERSIST, UPDATE_STATE, } from '@redux-devtools/app'; import type { Options, OptionsMessage } from '../../options/syncOptions'; import openDevToolsWindow, { DevToolsPosition } from '../openWindow'; import { getReport } from '../logging'; import { Action, Dispatch, Middleware } from 'redux'; import type { ContentScriptToBackgroundMessage, SplitMessage, } from '../../contentScript'; import type { ErrorMessage, PageScriptToContentScriptMessageForwardedToMonitors, PageScriptToContentScriptMessageWithoutDisconnectOrInitInstance, } from '../../pageScript/api'; import { LiftedState } from '@redux-devtools/instrument'; import type { BackgroundAction, LiftedActionAction } from './backgroundStore'; import type { Position } from '../../pageScript/api/openWindow'; import type { BackgroundState } from './backgroundReducer'; import { store } from '../index'; 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 OptionsAction { readonly type: 'OPTIONS'; readonly options: Options; } 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; } export 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; } type SplitUpdateStateRequestStart> = { split: 'start'; } & Partial>; interface SplitUpdateStateRequestChunk { readonly split: 'chunk'; readonly chunk: [string, string]; } interface SplitUpdateStateRequestEnd { readonly split: 'end'; } export type SplitUpdateStateRequest> = | SplitUpdateStateRequestStart | SplitUpdateStateRequestChunk | SplitUpdateStateRequestEnd; interface SplitUpdateStateAction> { readonly type: typeof UPDATE_STATE; request: SplitUpdateStateRequest; readonly id: string | number; } export type TabMessage = | StartAction | StopAction | OptionsAction | DispatchAction | ImportAction | ActionAction | ExportAction; export type PanelMessageWithoutNA> = | ErrorMessage | UpdateStateAction | SetPersistAction; export type PanelMessage> = | PanelMessageWithoutNA | NAAction; export type PanelMessageWithSplitAction> = | PanelMessage | SplitUpdateStateAction; export type MonitorMessage = | NAAction | ErrorMessage | EmptyUpdateStateAction | SetPersistAction; type TabPort = Omit & { postMessage: (message: TabMessage) => void; }; type PanelPort = Omit & { postMessage: >( message: PanelMessageWithSplitAction, ) => 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 | SetPersistAction; // Chrome message limit is 64 MB, but we're using 32 MB to include other object's parts const maxChromeMsgSize = 32 * 1024 * 1024; function toMonitors>(action: MonitorAction) { for (const port of [ ...Object.values(connections.monitor), ...Object.values(connections.panel), ]) { try { port.postMessage(action); } catch (err) { if ( action.type !== UPDATE_STATE || err == null || (err as Error).message !== 'Message length exceeded maximum allowed length.' ) { throw err; } const splitMessageStart: SplitUpdateStateRequestStart = { split: 'start', }; const toSplit: [string, string][] = []; let size = 0; for (const [key, value] of Object.entries( action.request as unknown as Record, )) { if (typeof value === 'string') { size += value.length; if (size > maxChromeMsgSize) { toSplit.push([key, value]); continue; } } (splitMessageStart as any)[key as keyof typeof splitMessageStart] = value; } port.postMessage({ ...action, request: splitMessageStart }); for (let i = 0; i < toSplit.length; i++) { for (let j = 0; j < toSplit[i][1].length; j += maxChromeMsgSize) { port.postMessage({ ...action, request: { split: 'chunk', chunk: [ toSplit[i][0], toSplit[i][1].substring(j, j + maxChromeMsgSize), ], }, }); } } port.postMessage({ ...action, request: { split: 'end' } }); } } } 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( 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( 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( 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( 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( store, message, instanceId, action as AppDispatchAction, state, ), id: (instanceId as number).toString().replace(/^[^\/]+\//, ''), }); } } function toAllTabs(msg: TabMessage) { for (const tabPort of Object.values(connections.tab)) { tabPort.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 = 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 = store.getState(); if (state.instances.persisted) { Object.keys(state.instances.connections).forEach((id) => { if (connections.tab[id]) return; store.dispatch({ type: REMOVE_INSTANCE, id }); toMonitors({ type: 'NA', id }); }); } } interface OpenMessage { readonly type: 'OPEN'; readonly position: Position; } interface OpenOptionsMessage { readonly type: 'OPEN_OPTIONS'; } export type SingleMessage = OpenMessage | OpenOptionsMessage | OptionsMessage; type BackgroundStoreMessage> = | PageScriptToContentScriptMessageWithoutDisconnectOrInitInstance | SplitMessage | SingleMessage; // Receive messages from content scripts function messaging>( request: BackgroundStoreMessage, sender: chrome.runtime.MessageSender, ) { let tabId = getId(sender); if (!tabId) return; if (sender.frameId) tabId = `${tabId}-${sender.frameId}`; if (request.type === 'STOP') { if (!Object.keys(store.getState().instances.connections).length) { store.dispatch({ type: DISCONNECTED }); } return; } if (request.type === 'OPEN_OPTIONS') { chrome.runtime.openOptionsPage(); return; } if (request.type === 'OPTIONS') { toAllTabs({ type: 'OPTIONS', options: request.options }); return; } if (request.type === 'GET_REPORT') { getReport(request.payload, tabId, request.instanceId); return; } if (request.type === 'OPEN') { let position: DevToolsPosition = 'devtools-window'; if (['remote', 'window'].includes(request.position)) { position = ('devtools-' + request.position) as DevToolsPosition; } openDevToolsWindow(position); return; } if (request.type === 'ERROR') { if (request.payload) { toMonitors(request); 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; } store.dispatch(action); toMonitors(action); } 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 (!store.getState().instances.persisted) { 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; 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 | 'heartbeat') => { if (msg === 'heartbeat') return; if (msg.name === 'INIT_INSTANCE') { if (typeof id === 'number') { chrome.action.enable(id); chrome.action.setIcon({ tabId: id, path: 'img/logo/38x38.png' }); } if (isMonitored) port.postMessage({ type: 'START' }); const state = store.getState(); if (state.instances.persisted) { 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); listener = (msg: BackgroundAction | 'heartbeat') => { if (msg === 'heartbeat') return; store.dispatch(msg); }; port.onMessage.addListener(listener); 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 | 'heartbeat') => { if (msg === 'heartbeat') return; 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-window'); }); const api: Middleware<{}, BackgroundState, Dispatch> = (store) => (next) => (untypedAction) => { const action = untypedAction as BackgroundAction; if (action.type === LIFTED_ACTION) toContentScript(action); else if (action.type === TOGGLE_PERSIST) { togglePersist(); toMonitors({ type: SET_PERSIST, payload: !store.getState().instances.persisted, }); } return next(action); }; export default api;