UNPKG

chrome-devtools-frontend

Version:
757 lines (609 loc) • 24.6 kB
// Copyright 2011 The Chromium Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import * as InspectorBackendCommands from '../../generated/InspectorBackendCommands.js'; import type * as ProtocolProxyApi from '../../generated/protocol-proxy-api.js'; import type * as Protocol from '../../generated/protocol.js'; import type * as Platform from '../platform/platform.js'; import { type CDPConnection, type CDPConnectionObserver, CDPErrorStatus, type CDPEvent, type Command, type CommandParams, type Event } from './CDPConnection.js'; import {ConnectionTransport} from './ConnectionTransport.js'; import {DevToolsCDPConnection} from './DevToolsCDPConnection.js'; // eslint-disable-next-line @typescript-eslint/no-explicit-any type MessageParams = Record<string, any>; type ProtocolDomainName = ProtocolProxyApi.ProtocolDomainName; export interface MessageError { code: number; message: string; data?: string|null; } export interface Message { sessionId?: string; url?: Platform.DevToolsPath.UrlString; id?: number; error?: MessageError|null; result?: Object|null; method?: QualifiedName; params?: MessageParams|null; } export interface EventMessage extends Message { method: QualifiedName; params?: MessageParams|null; } /** A qualified name, e.g. Domain.method */ export type QualifiedName = string&{qualifiedEventNameTag: string | undefined}; /** A qualified name, e.g. method */ export type UnqualifiedName = string&{unqualifiedEventNameTag: string | undefined}; export const splitQualifiedName = (string: QualifiedName): [string, UnqualifiedName] => { const [domain, eventName] = string.split('.'); return [domain, eventName as UnqualifiedName]; }; export const qualifyName = (domain: string, name: UnqualifiedName): QualifiedName => { return `${domain}.${name}` as QualifiedName; }; type EventParameterNames = Map<QualifiedName, string[]>; type ReadonlyEventParameterNames = ReadonlyMap<QualifiedName, string[]>; type CommandParameter = InspectorBackendCommands.CommandParameter; export class InspectorBackend implements InspectorBackendCommands.InspectorBackendAPI { readonly agentPrototypes = new Map<ProtocolDomainName, AgentPrototype>(); #eventParameterNamesForDomain = new Map<ProtocolDomainName, EventParameterNames>(); readonly typeMap = new Map<QualifiedName, CommandParameter[]>(); readonly enumMap = new Map<QualifiedName, Record<string, string>>(); constructor() { // Create the global here because registering commands will involve putting // items onto the global. // @ts-expect-error Global namespace instantiation globalThis.Protocol ||= {}; InspectorBackendCommands.registerCommands(this); } private getOrCreateEventParameterNamesForDomain(domain: ProtocolDomainName): EventParameterNames { let map = this.#eventParameterNamesForDomain.get(domain); if (!map) { map = new Map(); this.#eventParameterNamesForDomain.set(domain, map); } return map; } getOrCreateEventParameterNamesForDomainForTesting(domain: ProtocolDomainName): EventParameterNames { return this.getOrCreateEventParameterNamesForDomain(domain); } getEventParameterNames(): ReadonlyMap<ProtocolDomainName, ReadonlyEventParameterNames> { return this.#eventParameterNamesForDomain; } static reportProtocolError(error: string, messageObject: Object): void { console.error(error + ': ' + JSON.stringify(messageObject)); } static reportProtocolWarning(error: string, messageObject: Object): void { console.warn(error + ': ' + JSON.stringify(messageObject)); } private agentPrototype(domain: ProtocolDomainName): AgentPrototype { let prototype = this.agentPrototypes.get(domain); if (!prototype) { prototype = new AgentPrototype(domain); this.agentPrototypes.set(domain, prototype); } return prototype; } registerCommand(method: QualifiedName, parameters: CommandParameter[], replyArgs: string[], description: string): void { const [domain, command] = splitQualifiedName(method); this.agentPrototype(domain as ProtocolDomainName).registerCommand(command, parameters, replyArgs, description); } registerEnum(type: QualifiedName, values: Record<string, string>): void { const [domain, name] = splitQualifiedName(type); // @ts-expect-error globalThis global namespace pollution if (!globalThis.Protocol[domain]) { // @ts-expect-error globalThis global namespace pollution globalThis.Protocol[domain] = {}; } // @ts-expect-error globalThis global namespace pollution globalThis.Protocol[domain][name] = values; this.enumMap.set(type, values); } registerType(method: QualifiedName, parameters: CommandParameter[]): void { this.typeMap.set(method, parameters); } registerEvent(eventName: QualifiedName, params: string[]): void { const domain = eventName.split('.')[0]; const eventParameterNames = this.getOrCreateEventParameterNamesForDomain(domain as ProtocolDomainName); eventParameterNames.set(eventName, params); } } type SendRawMessageCallback = (...args: unknown[]) => void; export const test = { /** * This will get called for every protocol message. * ProtocolClient.test.dumpProtocol = console.log */ dumpProtocol: null as ((arg0: string) => void) | null, /** * Runs a function when no protocol activity is present. * ProtocolClient.test.deprecatedRunAfterPendingDispatches(() => console.log('done')) */ deprecatedRunAfterPendingDispatches: null as ((arg0: () => void) => void) | null, /** * Sends a raw message over main connection. * ProtocolClient.test.sendRawMessage('Page.enable', {}, console.log) */ sendRawMessage: null as ((method: QualifiedName, args: Object|null, arg2: SendRawMessageCallback) => void) | null, /** * Set to true to not log any errors. */ suppressRequestErrors: false as boolean, /** * Set to get notified about any messages sent over protocol. */ onMessageSent: null as ((message: {domain: string, method: string, params: Object, id: number, sessionId?: string}) => void) | null, /** * Set to get notified about any messages received over protocol. */ onMessageReceived: null as ((message: Object) => void) | null, }; export class SessionRouter implements CDPConnectionObserver { readonly #connection: CDPConnection; readonly #sessions = new Map<string, { target: TargetBase, }>(); constructor(connection: CDPConnection) { this.#connection = connection; this.#connection.observe(this); } registerSession(target: TargetBase, sessionId: string): void { this.#sessions.set(sessionId, {target}); } unregisterSession(sessionId: string): void { const session = this.#sessions.get(sessionId); if (!session) { return; } if (this.#connection instanceof DevToolsCDPConnection) { this.#connection.resolvePendingCalls(sessionId); } this.#sessions.delete(sessionId); } onDisconnect(reason: string): void { const session = this.#sessions.get(''); if (session) { session.target.dispose(reason); } } onEvent<T extends Event>(event: CDPEvent<T>): void { const sessionId = event.sessionId || ''; const session = this.#sessions.get(sessionId); session?.target.dispatch(event as unknown as EventMessage); } get connection(): CDPConnection { return this.#connection; } } /** * Make sure that `Domain` in get/set is only ever instantiated with one protocol domain * name, because if `Domain` allows multiple domains, the type is unsound. */ interface AgentsMap extends Map<ProtocolDomainName, ProtocolProxyApi.ProtocolApi[ProtocolDomainName]> { get<Domain extends ProtocolDomainName>(key: Domain): ProtocolProxyApi.ProtocolApi[Domain]|undefined; set<Domain extends ProtocolDomainName>(key: Domain, value: ProtocolProxyApi.ProtocolApi[Domain]): this; } /** * Make sure that `Domain` in get/set is only ever instantiated with one protocol domain * name, because if `Domain` allows multiple domains, the type is unsound. */ interface DispatcherMap extends Map<ProtocolDomainName, ProtocolProxyApi.ProtocolDispatchers[ProtocolDomainName]> { get<Domain extends ProtocolDomainName>(key: Domain): DispatcherManager<Domain>|undefined; set<Domain extends ProtocolDomainName>(key: Domain, value: DispatcherManager<Domain>): this; } export class TargetBase { readonly sessionId: string; #router: SessionRouter|null; #agents: AgentsMap = new Map(); #dispatchers: DispatcherMap = new Map(); constructor(parentTarget: TargetBase|null, sessionId: string, connection: CDPConnection|null) { this.sessionId = sessionId; if (parentTarget && !sessionId) { throw new Error('Specifying a parent target requires a session ID'); } let router: SessionRouter; if (parentTarget && parentTarget.#router) { router = parentTarget.#router; } else if (connection) { router = new SessionRouter(connection); } else { router = new SessionRouter(new DevToolsCDPConnection(ConnectionTransport.getFactory()())); } this.#router = router; router.registerSession(this, this.sessionId); for (const [domain, agentPrototype] of inspectorBackend.agentPrototypes) { const agent = Object.create((agentPrototype)); agent.target = this; this.#agents.set(domain, agent); } for (const [domain, eventParameterNames] of inspectorBackend.getEventParameterNames().entries()) { this.#dispatchers.set(domain, new DispatcherManager(eventParameterNames)); } } dispatch(eventMessage: EventMessage): void { const [domainName, method] = splitQualifiedName(eventMessage.method); const dispatcher = this.#dispatchers.get(domainName as ProtocolDomainName); if (!dispatcher) { InspectorBackend.reportProtocolError( `Protocol Error: the message ${eventMessage.method} is for non-existing domain '${domainName}'`, eventMessage); return; } dispatcher.dispatch(method, eventMessage); } dispose(_reason: string): void { if (!this.#router) { return; } this.#router.unregisterSession(this.sessionId); this.#router = null; } isDisposed(): boolean { return !this.#router; } router(): SessionRouter|null { return this.#router; } // Agent accessors, keep alphabetically sorted. /** * Make sure that `Domain` is only ever instantiated with one protocol domain * name, because if `Domain` allows multiple domains, the type is unsound. */ private getAgent<Domain extends ProtocolDomainName>(domain: Domain): ProtocolProxyApi.ProtocolApi[Domain] { const agent = this.#agents.get<Domain>(domain); if (!agent) { throw new Error('Accessing undefined agent'); } return agent; } accessibilityAgent(): ProtocolProxyApi.AccessibilityApi { return this.getAgent('Accessibility'); } animationAgent(): ProtocolProxyApi.AnimationApi { return this.getAgent('Animation'); } auditsAgent(): ProtocolProxyApi.AuditsApi { return this.getAgent('Audits'); } autofillAgent(): ProtocolProxyApi.AutofillApi { return this.getAgent('Autofill'); } browserAgent(): ProtocolProxyApi.BrowserApi { return this.getAgent('Browser'); } backgroundServiceAgent(): ProtocolProxyApi.BackgroundServiceApi { return this.getAgent('BackgroundService'); } cacheStorageAgent(): ProtocolProxyApi.CacheStorageApi { return this.getAgent('CacheStorage'); } cssAgent(): ProtocolProxyApi.CSSApi { return this.getAgent('CSS'); } debuggerAgent(): ProtocolProxyApi.DebuggerApi { return this.getAgent('Debugger'); } deviceOrientationAgent(): ProtocolProxyApi.DeviceOrientationApi { return this.getAgent('DeviceOrientation'); } domAgent(): ProtocolProxyApi.DOMApi { return this.getAgent('DOM'); } domdebuggerAgent(): ProtocolProxyApi.DOMDebuggerApi { return this.getAgent('DOMDebugger'); } domsnapshotAgent(): ProtocolProxyApi.DOMSnapshotApi { return this.getAgent('DOMSnapshot'); } domstorageAgent(): ProtocolProxyApi.DOMStorageApi { return this.getAgent('DOMStorage'); } emulationAgent(): ProtocolProxyApi.EmulationApi { return this.getAgent('Emulation'); } eventBreakpointsAgent(): ProtocolProxyApi.EventBreakpointsApi { return this.getAgent('EventBreakpoints'); } extensionsAgent(): ProtocolProxyApi.ExtensionsApi { return this.getAgent('Extensions'); } fetchAgent(): ProtocolProxyApi.FetchApi { return this.getAgent('Fetch'); } heapProfilerAgent(): ProtocolProxyApi.HeapProfilerApi { return this.getAgent('HeapProfiler'); } indexedDBAgent(): ProtocolProxyApi.IndexedDBApi { return this.getAgent('IndexedDB'); } inputAgent(): ProtocolProxyApi.InputApi { return this.getAgent('Input'); } ioAgent(): ProtocolProxyApi.IOApi { return this.getAgent('IO'); } inspectorAgent(): ProtocolProxyApi.InspectorApi { return this.getAgent('Inspector'); } layerTreeAgent(): ProtocolProxyApi.LayerTreeApi { return this.getAgent('LayerTree'); } logAgent(): ProtocolProxyApi.LogApi { return this.getAgent('Log'); } mediaAgent(): ProtocolProxyApi.MediaApi { return this.getAgent('Media'); } memoryAgent(): ProtocolProxyApi.MemoryApi { return this.getAgent('Memory'); } networkAgent(): ProtocolProxyApi.NetworkApi { return this.getAgent('Network'); } overlayAgent(): ProtocolProxyApi.OverlayApi { return this.getAgent('Overlay'); } pageAgent(): ProtocolProxyApi.PageApi { return this.getAgent('Page'); } preloadAgent(): ProtocolProxyApi.PreloadApi { return this.getAgent('Preload'); } profilerAgent(): ProtocolProxyApi.ProfilerApi { return this.getAgent('Profiler'); } performanceAgent(): ProtocolProxyApi.PerformanceApi { return this.getAgent('Performance'); } runtimeAgent(): ProtocolProxyApi.RuntimeApi { return this.getAgent('Runtime'); } securityAgent(): ProtocolProxyApi.SecurityApi { return this.getAgent('Security'); } serviceWorkerAgent(): ProtocolProxyApi.ServiceWorkerApi { return this.getAgent('ServiceWorker'); } storageAgent(): ProtocolProxyApi.StorageApi { return this.getAgent('Storage'); } systemInfo(): ProtocolProxyApi.SystemInfoApi { return this.getAgent('SystemInfo'); } targetAgent(): ProtocolProxyApi.TargetApi { return this.getAgent('Target'); } tracingAgent(): ProtocolProxyApi.TracingApi { return this.getAgent('Tracing'); } webAudioAgent(): ProtocolProxyApi.WebAudioApi { return this.getAgent('WebAudio'); } webAuthnAgent(): ProtocolProxyApi.WebAuthnApi { return this.getAgent('WebAuthn'); } // Dispatcher registration and de-registration, keep alphabetically sorted. /** * Make sure that `Domain` is only ever instantiated with one protocol domain * name, because if `Domain` allows multiple domains, the type is unsound. */ private registerDispatcher<Domain extends ProtocolDomainName>( domain: Domain, dispatcher: ProtocolProxyApi.ProtocolDispatchers[Domain]): void { const manager = this.#dispatchers.get(domain); if (!manager) { return; } manager.addDomainDispatcher(dispatcher); } /** * Make sure that `Domain` is only ever instantiated with one protocol domain * name, because if `Domain` allows multiple domains, the type is unsound. */ private unregisterDispatcher<Domain extends ProtocolDomainName>( domain: Domain, dispatcher: ProtocolProxyApi.ProtocolDispatchers[Domain]): void { const manager = this.#dispatchers.get(domain); if (!manager) { return; } manager.removeDomainDispatcher(dispatcher); } registerAccessibilityDispatcher(dispatcher: ProtocolProxyApi.AccessibilityDispatcher): void { this.registerDispatcher('Accessibility', dispatcher); } registerAutofillDispatcher(dispatcher: ProtocolProxyApi.AutofillDispatcher): void { this.registerDispatcher('Autofill', dispatcher); } registerAnimationDispatcher(dispatcher: ProtocolProxyApi.AnimationDispatcher): void { this.registerDispatcher('Animation', dispatcher); } registerAuditsDispatcher(dispatcher: ProtocolProxyApi.AuditsDispatcher): void { this.registerDispatcher('Audits', dispatcher); } registerCSSDispatcher(dispatcher: ProtocolProxyApi.CSSDispatcher): void { this.registerDispatcher('CSS', dispatcher); } registerBackgroundServiceDispatcher(dispatcher: ProtocolProxyApi.BackgroundServiceDispatcher): void { this.registerDispatcher('BackgroundService', dispatcher); } registerDebuggerDispatcher(dispatcher: ProtocolProxyApi.DebuggerDispatcher): void { this.registerDispatcher('Debugger', dispatcher); } unregisterDebuggerDispatcher(dispatcher: ProtocolProxyApi.DebuggerDispatcher): void { this.unregisterDispatcher('Debugger', dispatcher); } registerDOMDispatcher(dispatcher: ProtocolProxyApi.DOMDispatcher): void { this.registerDispatcher('DOM', dispatcher); } registerDOMStorageDispatcher(dispatcher: ProtocolProxyApi.DOMStorageDispatcher): void { this.registerDispatcher('DOMStorage', dispatcher); } registerFetchDispatcher(dispatcher: ProtocolProxyApi.FetchDispatcher): void { this.registerDispatcher('Fetch', dispatcher); } registerHeapProfilerDispatcher(dispatcher: ProtocolProxyApi.HeapProfilerDispatcher): void { this.registerDispatcher('HeapProfiler', dispatcher); } registerInspectorDispatcher(dispatcher: ProtocolProxyApi.InspectorDispatcher): void { this.registerDispatcher('Inspector', dispatcher); } registerLayerTreeDispatcher(dispatcher: ProtocolProxyApi.LayerTreeDispatcher): void { this.registerDispatcher('LayerTree', dispatcher); } registerLogDispatcher(dispatcher: ProtocolProxyApi.LogDispatcher): void { this.registerDispatcher('Log', dispatcher); } registerMediaDispatcher(dispatcher: ProtocolProxyApi.MediaDispatcher): void { this.registerDispatcher('Media', dispatcher); } registerNetworkDispatcher(dispatcher: ProtocolProxyApi.NetworkDispatcher): void { this.registerDispatcher('Network', dispatcher); } registerOverlayDispatcher(dispatcher: ProtocolProxyApi.OverlayDispatcher): void { this.registerDispatcher('Overlay', dispatcher); } registerPageDispatcher(dispatcher: ProtocolProxyApi.PageDispatcher): void { this.registerDispatcher('Page', dispatcher); } registerPreloadDispatcher(dispatcher: ProtocolProxyApi.PreloadDispatcher): void { this.registerDispatcher('Preload', dispatcher); } registerProfilerDispatcher(dispatcher: ProtocolProxyApi.ProfilerDispatcher): void { this.registerDispatcher('Profiler', dispatcher); } registerRuntimeDispatcher(dispatcher: ProtocolProxyApi.RuntimeDispatcher): void { this.registerDispatcher('Runtime', dispatcher); } registerSecurityDispatcher(dispatcher: ProtocolProxyApi.SecurityDispatcher): void { this.registerDispatcher('Security', dispatcher); } registerServiceWorkerDispatcher(dispatcher: ProtocolProxyApi.ServiceWorkerDispatcher): void { this.registerDispatcher('ServiceWorker', dispatcher); } registerStorageDispatcher(dispatcher: ProtocolProxyApi.StorageDispatcher): void { this.registerDispatcher('Storage', dispatcher); } registerTargetDispatcher(dispatcher: ProtocolProxyApi.TargetDispatcher): void { this.registerDispatcher('Target', dispatcher); } registerTracingDispatcher(dispatcher: ProtocolProxyApi.TracingDispatcher): void { this.registerDispatcher('Tracing', dispatcher); } registerWebAudioDispatcher(dispatcher: ProtocolProxyApi.WebAudioDispatcher): void { this.registerDispatcher('WebAudio', dispatcher); } registerWebAuthnDispatcher(dispatcher: ProtocolProxyApi.WebAuthnDispatcher): void { this.registerDispatcher('WebAuthn', dispatcher); } } /** These are not logged as console.error */ const IGNORED_ERRORS = new Set<CDPErrorStatus>([ CDPErrorStatus.DEVTOOLS_REHYDRATION_ERROR, CDPErrorStatus.DEVTOOLS_STUB_ERROR, CDPErrorStatus.SERVER_ERROR, CDPErrorStatus.SESSION_NOT_FOUND, ]); /** * This is a class that serves as the prototype for a domains #agents (every target * has it's own set of #agents). The InspectorBackend keeps an instance of this class * per domain, and each TargetBase creates its #agents (via Object.create) and installs * this instance as prototype. * * The reasons this is done is so that on the prototypes we can install the implementations * of the invoke_enable, etc. methods that the front-end uses. */ class AgentPrototype { description = ''; metadata: Record<string, {parameters: CommandParameter[], description: string, replyArgs: string[]}>; readonly domain: string; target!: TargetBase; constructor(domain: string) { this.domain = domain; this.metadata = {}; } registerCommand( methodName: UnqualifiedName, parameters: CommandParameter[], replyArgs: string[], description: string): void { const domainAndMethod = qualifyName(this.domain, methodName); this.metadata[domainAndMethod] = {parameters, description, replyArgs}; function invoke(this: AgentPrototype, request: Object|undefined = {}): Promise<Protocol.ProtocolResponseWithError> { return this.invoke(domainAndMethod, request); } // @ts-expect-error Method code generation this['invoke_' + methodName] = invoke; } private invoke(method: QualifiedName, request: Object|null): Promise<Protocol.ProtocolResponseWithError> { const connection = this.target.router()?.connection; if (!connection) { return Promise.resolve( {result: null, getError: () => `Connection is closed, can\'t dispatch pending call to ${method}`}); } return connection.send(method as Command, request as CommandParams<Command>, this.target.sessionId) .then(response => { if ('error' in response && response.error) { if (!test.suppressRequestErrors && !IGNORED_ERRORS.has(response.error.code)) { console.error('Request ' + method + ' failed. ' + JSON.stringify(response.error)); } return {getError: () => response.error.message}; } if ('result' in response) { return {...response.result, getError: () => undefined}; } return { getError: () => `Command ${method} returned neither result nor an error, params: ${ JSON.stringify(request, undefined, 2)}`, }; }); } } /** * A `DispatcherManager` has a collection of #dispatchers that implement one of the * `ProtocolProxyApi.{Foo}Dispatcher` interfaces. Each target uses one of these per * domain to manage the registered #dispatchers. The class knows the parameter names * of the events via `#eventArgs`, which is a map managed by the inspector back-end * so that there is only one map per domain that is shared among all DispatcherManagers. */ class DispatcherManager<Domain extends ProtocolDomainName> { readonly #eventArgs: ReadonlyEventParameterNames; readonly #dispatchers: Array<ProtocolProxyApi.ProtocolDispatchers[Domain]> = []; constructor(eventArgs: ReadonlyEventParameterNames) { this.#eventArgs = eventArgs; } addDomainDispatcher(dispatcher: ProtocolProxyApi.ProtocolDispatchers[Domain]): void { this.#dispatchers.push(dispatcher); } removeDomainDispatcher(dispatcher: ProtocolProxyApi.ProtocolDispatchers[Domain]): void { const index = this.#dispatchers.indexOf(dispatcher); if (index === -1) { return; } this.#dispatchers.splice(index, 1); } dispatch(event: UnqualifiedName, messageObject: EventMessage): void { if (!this.#dispatchers.length) { return; } if (!this.#eventArgs.has(messageObject.method)) { InspectorBackend.reportProtocolWarning( `Protocol Warning: Attempted to dispatch an unspecified event '${messageObject.method}'`, messageObject); return; } for (let index = 0; index < this.#dispatchers.length; ++index) { const dispatcher = this.#dispatchers[index]; if (event in dispatcher) { const f = dispatcher[event as string as keyof ProtocolProxyApi.ProtocolDispatchers[Domain]]; // @ts-expect-error Can't type check the dispatch. f.call(dispatcher, messageObject.params); } } } } export const inspectorBackend = new InspectorBackend();