diff options
Diffstat (limited to 'devtools/server/actors/webconsole.js')
-rw-r--r-- | devtools/server/actors/webconsole.js | 2215 |
1 files changed, 2215 insertions, 0 deletions
diff --git a/devtools/server/actors/webconsole.js b/devtools/server/actors/webconsole.js new file mode 100644 index 0000000000..8c302cfd02 --- /dev/null +++ b/devtools/server/actors/webconsole.js @@ -0,0 +1,2215 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/* global XPCNativeWrapper */ +const { ActorClassWithSpec, Actor } = require("devtools/shared/protocol"); +const { webconsoleSpec } = require("devtools/shared/specs/webconsole"); + +const Services = require("Services"); +const { Cc, Ci, Cu } = require("chrome"); +const { DevToolsServer } = require("devtools/server/devtools-server"); +const { ThreadActor } = require("devtools/server/actors/thread"); +const { ObjectActor } = require("devtools/server/actors/object"); +const { LongStringActor } = require("devtools/server/actors/string"); +const { + createValueGrip, + isArray, + stringIsLong, +} = require("devtools/server/actors/object/utils"); +const DevToolsUtils = require("devtools/shared/DevToolsUtils"); +const ErrorDocs = require("devtools/server/actors/errordocs"); + +loader.lazyRequireGetter( + this, + "evalWithDebugger", + "devtools/server/actors/webconsole/eval-with-debugger", + true +); +loader.lazyRequireGetter( + this, + "NetworkMonitorActor", + "devtools/server/actors/network-monitor/network-monitor", + true +); +loader.lazyRequireGetter( + this, + "ConsoleFileActivityListener", + "devtools/server/actors/webconsole/listeners/console-file-activity", + true +); +loader.lazyRequireGetter( + this, + "StackTraceCollector", + "devtools/server/actors/network-monitor/stack-trace-collector", + true +); +loader.lazyRequireGetter( + this, + "JSPropertyProvider", + "devtools/shared/webconsole/js-property-provider", + true +); +loader.lazyRequireGetter( + this, + "NetUtil", + "resource://gre/modules/NetUtil.jsm", + true +); +loader.lazyRequireGetter( + this, + ["isCommand", "validCommands"], + "devtools/server/actors/webconsole/commands", + true +); +loader.lazyRequireGetter( + this, + "createMessageManagerMocks", + "devtools/server/actors/webconsole/message-manager-mock", + true +); +loader.lazyRequireGetter( + this, + ["addWebConsoleCommands", "CONSOLE_WORKER_IDS", "WebConsoleUtils"], + "devtools/server/actors/webconsole/utils", + true +); +loader.lazyRequireGetter( + this, + "EnvironmentActor", + "devtools/server/actors/environment", + true +); +loader.lazyRequireGetter(this, "EventEmitter", "devtools/shared/event-emitter"); +loader.lazyRequireGetter( + this, + "MESSAGE_CATEGORY", + "devtools/shared/constants", + true +); +loader.lazyRequireGetter( + this, + "stringToCauseType", + "devtools/server/actors/network-monitor/network-observer", + true +); + +// Generated by /devtools/shared/webconsole/GenerateReservedWordsJS.py +loader.lazyRequireGetter( + this, + "RESERVED_JS_KEYWORDS", + "devtools/shared/webconsole/reserved-js-words" +); + +// Overwrite implemented listeners for workers so that we don't attempt +// to load an unsupported module. +if (isWorker) { + loader.lazyRequireGetter( + this, + ["ConsoleAPIListener", "ConsoleServiceListener"], + "devtools/server/actors/webconsole/worker-listeners", + true + ); +} else { + loader.lazyRequireGetter( + this, + "ConsoleAPIListener", + "devtools/server/actors/webconsole/listeners/console-api", + true + ); + loader.lazyRequireGetter( + this, + "ConsoleServiceListener", + "devtools/server/actors/webconsole/listeners/console-service", + true + ); + loader.lazyRequireGetter( + this, + "ConsoleReflowListener", + "devtools/server/actors/webconsole/listeners/console-reflow", + true + ); + loader.lazyRequireGetter( + this, + "ContentProcessListener", + "devtools/server/actors/webconsole/listeners/content-process", + true + ); + loader.lazyRequireGetter( + this, + "DocumentEventsListener", + "devtools/server/actors/webconsole/listeners/document-events", + true + ); +} +loader.lazyRequireGetter( + this, + "ObjectUtils", + "devtools/server/actors/object/utils" +); + +function isObject(value) { + return Object(value) === value; +} + +/** + * The WebConsoleActor implements capabilities needed for the Web Console + * feature. + * + * @constructor + * @param object connection + * The connection to the client, DevToolsServerConnection. + * @param object [parentActor] + * Optional, the parent actor. + */ +const WebConsoleActor = ActorClassWithSpec(webconsoleSpec, { + initialize: function(connection, parentActor) { + Actor.prototype.initialize.call(this, connection); + this.conn = connection; + this.parentActor = parentActor; + + this._prefs = {}; + this.dbg = this.parentActor.dbg; + + this._gripDepth = 0; + this._evalCounter = 0; + this._listeners = new Set(); + this._lastConsoleInputEvaluation = undefined; + + this.objectGrip = this.objectGrip.bind(this); + this._onWillNavigate = this._onWillNavigate.bind(this); + this._onChangedToplevelDocument = this._onChangedToplevelDocument.bind( + this + ); + this.onConsoleServiceMessage = this.onConsoleServiceMessage.bind(this); + this.onConsoleAPICall = this.onConsoleAPICall.bind(this); + this.onDocumentEvent = this.onDocumentEvent.bind(this); + + EventEmitter.on( + this.parentActor, + "changed-toplevel-document", + this._onChangedToplevelDocument + ); + this._onObserverNotification = this._onObserverNotification.bind(this); + if (this.parentActor.isRootActor) { + Services.obs.addObserver( + this._onObserverNotification, + "last-pb-context-exited" + ); + } + + this.traits = { + // Supports retrieving blocked urls + blockedUrls: true, + }; + }, + /** + * Debugger instance. + * + * @see jsdebugger.jsm + */ + dbg: null, + + /** + * This is used by the ObjectActor to keep track of the depth of grip() calls. + * @private + * @type number + */ + _gripDepth: null, + + /** + * Web Console-related preferences. + * @private + * @type object + */ + _prefs: null, + + /** + * Holds a set of all currently registered listeners. + * + * @private + * @type Set + */ + _listeners: null, + + /** + * The devtools server connection instance. + * @type object + */ + conn: null, + + /** + * List of supported features by the console actor. + * @type object + */ + traits: null, + + /** + * The global we work with (this can be a Window, a Worker global or even a Sandbox + * for processes and addons). + * + * @type nsIDOMWindow, WorkerGlobalScope or Sandbox + */ + get global() { + if (this.parentActor.isRootActor) { + return this._getWindowForBrowserConsole(); + } + return this.parentActor.window || this.parentActor.workerGlobal; + }, + + /** + * Get a window to use for the browser console. + * + * @private + * @return nsIDOMWindow + * The window to use, or null if no window could be found. + */ + _getWindowForBrowserConsole: function() { + // Check if our last used chrome window is still live. + let window = this._lastChromeWindow && this._lastChromeWindow.get(); + // If not, look for a new one. + if (!window || window.closed) { + window = this.parentActor.window; + if (!window) { + // Try to find the Browser Console window to use instead. + window = Services.wm.getMostRecentWindow("devtools:webconsole"); + // We prefer the normal chrome window over the console window, + // so we'll look for those windows in order to replace our reference. + const onChromeWindowOpened = () => { + // We'll look for this window when someone next requests window() + Services.obs.removeObserver(onChromeWindowOpened, "domwindowopened"); + this._lastChromeWindow = null; + }; + Services.obs.addObserver(onChromeWindowOpened, "domwindowopened"); + } + + this._handleNewWindow(window); + } + + return window; + }, + + /** + * Store a newly found window on the actor to be used in the future. + * + * @private + * @param nsIDOMWindow window + * The window to store on the actor (can be null). + */ + _handleNewWindow: function(window) { + if (window) { + if (this._hadChromeWindow) { + Services.console.logStringMessage("Webconsole context has changed"); + } + this._lastChromeWindow = Cu.getWeakReference(window); + this._hadChromeWindow = true; + } else { + this._lastChromeWindow = null; + } + }, + + /** + * Whether we've been using a window before. + * + * @private + * @type boolean + */ + _hadChromeWindow: false, + + /** + * A weak reference to the last chrome window we used to work with. + * + * @private + * @type nsIWeakReference + */ + _lastChromeWindow: null, + + // The evalGlobal is used at the scope for JS evaluation. + _evalGlobal: null, + get evalGlobal() { + return this._evalGlobal || this.global; + }, + + set evalGlobal(global) { + this._evalGlobal = global; + + if (!this._progressListenerActive) { + EventEmitter.on(this.parentActor, "will-navigate", this._onWillNavigate); + this._progressListenerActive = true; + } + }, + + /** + * Flag used to track if we are listening for events from the progress + * listener of the target actor. We use the progress listener to clear + * this.evalGlobal on page navigation. + * + * @private + * @type boolean + */ + _progressListenerActive: false, + + /** + * The ConsoleServiceListener instance. + * @type object + */ + consoleServiceListener: null, + + /** + * The ConsoleAPIListener instance. + */ + consoleAPIListener: null, + + /** + * The ConsoleFileActivityListener instance. + */ + consoleFileActivityListener: null, + + /** + * The ConsoleReflowListener instance. + */ + consoleReflowListener: null, + + /** + * The Web Console Commands names cache. + * @private + * @type array + */ + _webConsoleCommandsCache: null, + + grip: function() { + return { actor: this.actorID }; + }, + + hasNativeConsoleAPI: function(window) { + if (isWorker || !(window instanceof Ci.nsIDOMWindow)) { + // We can only use XPCNativeWrapper on non-worker nsIDOMWindow. + return true; + } + + let isNative = false; + try { + // We are very explicitly examining the "console" property of + // the non-Xrayed object here. + const console = window.wrappedJSObject.console; + // In xpcshell tests, console ends up being undefined and XPCNativeWrapper + // crashes in debug builds. + if (console) { + isNative = new XPCNativeWrapper(console).IS_NATIVE_CONSOLE; + } + } catch (ex) { + // ignored + } + return isNative; + }, + + _findProtoChain: ThreadActor.prototype._findProtoChain, + _removeFromProtoChain: ThreadActor.prototype._removeFromProtoChain, + + /** + * Destroy the current WebConsoleActor instance. + */ + destroy() { + this.stopListeners(); + Actor.prototype.destroy.call(this); + + EventEmitter.off( + this.parentActor, + "changed-toplevel-document", + this._onChangedToplevelDocument + ); + + if (this.parentActor.isRootActor) { + Services.obs.removeObserver( + this._onObserverNotification, + "last-pb-context-exited" + ); + } + + this._webConsoleCommandsCache = null; + this._lastConsoleInputEvaluation = null; + this._evalGlobal = null; + this.dbg = null; + this.conn = null; + }, + + /** + * Create and return an environment actor that corresponds to the provided + * Debugger.Environment. This is a straightforward clone of the ThreadActor's + * method except that it stores the environment actor in the web console + * actor's pool. + * + * @param Debugger.Environment environment + * The lexical environment we want to extract. + * @return The EnvironmentActor for |environment| or |undefined| for host + * functions or functions scoped to a non-debuggee global. + */ + createEnvironmentActor: function(environment) { + if (!environment) { + return undefined; + } + + if (environment.actor) { + return environment.actor; + } + + const actor = new EnvironmentActor(environment, this); + this.manage(actor); + environment.actor = actor; + + return actor; + }, + + /** + * Create a grip for the given value. + * + * @param mixed value + * @return object + */ + createValueGrip: function(value) { + return createValueGrip(value, this, this.objectGrip); + }, + + /** + * Make a debuggee value for the given value. + * + * @param mixed value + * The value you want to get a debuggee value for. + * @param boolean useObjectGlobal + * If |true| the object global is determined and added as a debuggee, + * otherwise |this.global| is used when makeDebuggeeValue() is invoked. + * @return object + * Debuggee value for |value|. + */ + makeDebuggeeValue: function(value, useObjectGlobal) { + if (useObjectGlobal && isObject(value)) { + try { + const global = Cu.getGlobalForObject(value); + const dbgGlobal = this.dbg.makeGlobalObjectReference(global); + return dbgGlobal.makeDebuggeeValue(value); + } catch (ex) { + // The above can throw an exception if value is not an actual object + // or 'Object in compartment marked as invisible to Debugger' + } + } + const dbgGlobal = this.dbg.makeGlobalObjectReference(this.global); + return dbgGlobal.makeDebuggeeValue(value); + }, + + /** + * Create a grip for the given object. + * + * @param object object + * The object you want. + * @param object pool + * A Pool where the new actor instance is added. + * @param object + * The object grip. + */ + objectGrip: function(object, pool) { + const actor = new ObjectActor( + object, + { + thread: this.parentActor.threadActor, + getGripDepth: () => this._gripDepth, + incrementGripDepth: () => this._gripDepth++, + decrementGripDepth: () => this._gripDepth--, + createValueGrip: v => this.createValueGrip(v), + createEnvironmentActor: env => this.createEnvironmentActor(env), + }, + this.conn + ); + pool.manage(actor); + return actor.form(); + }, + + /** + * Create a grip for the given string. + * + * @param string string + * The string you want to create the grip for. + * @param object pool + * A Pool where the new actor instance is added. + * @return object + * A LongStringActor object that wraps the given string. + */ + longStringGrip: function(string, pool) { + const actor = new LongStringActor(this.conn, string); + pool.manage(actor); + return actor.form(); + }, + + /** + * Create a long string grip if needed for the given string. + * + * @private + * @param string string + * The string you want to create a long string grip for. + * @return string|object + * A string is returned if |string| is not a long string. + * A LongStringActor grip is returned if |string| is a long string. + */ + _createStringGrip: function(string) { + if (string && stringIsLong(string)) { + return this.longStringGrip(string, this); + } + return string; + }, + + /** + * Returns the latest web console input evaluation. + * This is undefined if no evaluations have been completed. + * + * @return object + */ + getLastConsoleInputEvaluation: function() { + return this._lastConsoleInputEvaluation; + }, + + /** + * Preprocess a debugger object (e.g. return the `boundTargetFunction` + * debugger object if the given debugger object is a bound function). + * + * This method is called by both the `inspect` binding implemented + * for the webconsole and the one implemented for the devtools API + * `browser.devtools.inspectedWindow.eval`. + */ + preprocessDebuggerObject(dbgObj) { + // Returns the bound target function on a bound function. + if (dbgObj?.isBoundFunction && dbgObj?.boundTargetFunction) { + return dbgObj.boundTargetFunction; + } + + return dbgObj; + }, + + /** + * This helper is used by the WebExtensionInspectedWindowActor to + * inspect an object in the developer toolbox. + * + * NOTE: shared parts related to preprocess the debugger object (between + * this function and the `inspect` webconsole command defined in + * "devtools/server/actor/webconsole/utils.js") should be added to + * the webconsole actors' `preprocessDebuggerObject` method. + */ + inspectObject(dbgObj, inspectFromAnnotation) { + dbgObj = this.preprocessDebuggerObject(dbgObj); + this.emit("inspectObject", { + objectActor: this.createValueGrip(dbgObj), + inspectFromAnnotation, + }); + }, + + // Request handlers for known packet types. + + /** + * Handler for the "startListeners" request. + * + * @param array listeners + * An array of events to start sent by the Web Console client. + * @return object + * The response object which holds the startedListeners array. + */ + // eslint-disable-next-line complexity + startListeners: async function(listeners) { + const startedListeners = []; + const global = !this.parentActor.isRootActor ? this.global : null; + + for (const event of listeners) { + switch (event) { + case "PageError": + // Workers don't support this message type yet + if (isWorker) { + break; + } + if (!this.consoleServiceListener) { + this.consoleServiceListener = new ConsoleServiceListener( + global, + this.onConsoleServiceMessage + ); + this.consoleServiceListener.init(); + } + startedListeners.push(event); + break; + case "ConsoleAPI": + if (!this.consoleAPIListener) { + // Create the consoleAPIListener + // (and apply the filtering options defined in the parent actor). + this.consoleAPIListener = new ConsoleAPIListener( + global, + this.onConsoleAPICall, + this.parentActor.consoleAPIListenerOptions + ); + this.consoleAPIListener.init(); + } + startedListeners.push(event); + break; + case "NetworkActivity": + // Workers don't support this message type + if (isWorker) { + break; + } + if (!this.netmonitors) { + // Instanciate fake message managers used for service worker's netmonitor + // when running in the content process, and for netmonitor running in the + // same process when running in the parent process. + // `createMessageManagerMocks` returns a couple of connected messages + // managers that pass messages to each other to simulate the process + // boundary. We will use the first one for the webconsole-actor and the + // second one will be used by the netmonitor-actor. + const [mmMockParent, mmMockChild] = createMessageManagerMocks(); + + // Maintain the list of message manager we should message to/listen from + // to support the netmonitor instances, also records actorID of each + // NetworkMonitorActor. + // Array of `{ messageManager, parentProcess }`. + // Where `parentProcess` is true for the netmonitor actor instanciated in the + // parent process. + this.netmonitors = []; + + // Check if the actor is running in a content process + const isInContentProcess = + Services.appinfo.processType != + Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT && + this.parentActor.messageManager; + if (isInContentProcess) { + // Start a network monitor in the parent process to listen to + // most requests that happen in parent. This one will communicate through + // `messageManager`. + await this.conn.spawnActorInParentProcess(this.actorID, { + module: + "devtools/server/actors/network-monitor/network-monitor", + constructor: "NetworkMonitorActor", + args: [{ browserId: this.parentActor.browserId }, this.actorID], + }); + this.netmonitors.push({ + messageManager: this.parentActor.messageManager, + parentProcess: true, + }); + } + + // When the console actor runs in the parent process, Netmonitor can be ran + // in the process and communicate through `messageManagerMock`. + // And while it runs in the content process, we also spawn one in the content + // to listen to requests that happen in the content process (for instance + // service workers requests) + new NetworkMonitorActor( + this.conn, + { window: global }, + this.actorID, + mmMockParent + ); + + this.netmonitors.push({ + messageManager: mmMockChild, + parentProcess: !isInContentProcess, + }); + + // Create a StackTraceCollector that's going to be shared both by + // the NetworkMonitorActor running in the same process for service worker + // requests, as well with the NetworkMonitorActor running in the parent + // process. It will communicate via message manager for this one. + this.stackTraceCollector = new StackTraceCollector( + { window: global }, + this.netmonitors + ); + this.stackTraceCollector.init(); + } + startedListeners.push(event); + break; + case "FileActivity": + // Workers don't support this message type + if (isWorker) { + break; + } + if (this.global instanceof Ci.nsIDOMWindow) { + if (!this.consoleFileActivityListener) { + this.consoleFileActivityListener = new ConsoleFileActivityListener( + this.global, + this + ); + } + this.consoleFileActivityListener.startMonitor(); + startedListeners.push(event); + } + break; + case "ReflowActivity": + // Workers don't support this message type + if (isWorker) { + break; + } + if (!this.consoleReflowListener) { + this.consoleReflowListener = new ConsoleReflowListener( + this.global, + this + ); + } + startedListeners.push(event); + break; + case "ContentProcessMessages": + // Workers don't support this message type + if (isWorker) { + break; + } + if (!this.contentProcessListener) { + this.contentProcessListener = new ContentProcessListener( + this.onConsoleAPICall + ); + } + startedListeners.push(event); + break; + case "DocumentEvents": + // Workers don't support this message type + if (isWorker) { + break; + } + if (!this.documentEventsListener) { + this.documentEventsListener = new DocumentEventsListener( + this.parentActor + ); + this.documentEventsListener.on("*", this.onDocumentEvent); + this.documentEventsListener.listen(); + } + startedListeners.push(event); + break; + } + } + + // Update the live list of running listeners + startedListeners.forEach(this._listeners.add, this._listeners); + + return { + startedListeners: startedListeners, + nativeConsoleAPI: this.hasNativeConsoleAPI(this.global), + traits: this.traits, + }; + }, + + /** + * Handler for the "stopListeners" request. + * + * @param array listeners + * An array of events to stop sent by the Web Console client. + * @return object + * The response packet to send to the client: holds the + * stoppedListeners array. + */ + stopListeners: function(listeners) { + const stoppedListeners = []; + + // If no specific listeners are requested to be detached, we stop all + // listeners. + const eventsToDetach = listeners || [ + "PageError", + "ConsoleAPI", + "NetworkActivity", + "FileActivity", + "ReflowActivity", + "ContentProcessMessages", + "DocumentEvents", + ]; + + for (const event of eventsToDetach) { + switch (event) { + case "PageError": + if (this.consoleServiceListener) { + this.consoleServiceListener.destroy(); + this.consoleServiceListener = null; + } + stoppedListeners.push(event); + break; + case "ConsoleAPI": + if (this.consoleAPIListener) { + this.consoleAPIListener.destroy(); + this.consoleAPIListener = null; + } + stoppedListeners.push(event); + break; + case "NetworkActivity": + if (this.netmonitors) { + for (const { messageManager } of this.netmonitors) { + messageManager.sendAsyncMessage("debug:destroy-network-monitor", { + actorID: this.actorID, + }); + } + this.netmonitors = null; + } + if (this.stackTraceCollector) { + this.stackTraceCollector.destroy(); + this.stackTraceCollector = null; + } + stoppedListeners.push(event); + break; + case "FileActivity": + if (this.consoleFileActivityListener) { + this.consoleFileActivityListener.stopMonitor(); + this.consoleFileActivityListener = null; + } + stoppedListeners.push(event); + break; + case "ReflowActivity": + if (this.consoleReflowListener) { + this.consoleReflowListener.destroy(); + this.consoleReflowListener = null; + } + stoppedListeners.push(event); + break; + case "ContentProcessMessages": + if (this.contentProcessListener) { + this.contentProcessListener.destroy(); + this.contentProcessListener = null; + } + stoppedListeners.push(event); + break; + case "DocumentEvents": + if (this.documentEventsListener) { + this.documentEventsListener.destroy(); + this.documentEventsListener = null; + } + stoppedListeners.push(event); + break; + } + } + + // Update the live list of running listeners + stoppedListeners.forEach(this._listeners.delete, this._listeners); + + return { stoppedListeners: stoppedListeners }; + }, + + /** + * Handler for the "getCachedMessages" request. This method sends the cached + * error messages and the window.console API calls to the client. + * + * @param array messageTypes + * An array of message types sent by the Web Console client. + * @return object + * The response packet to send to the client: it holds the cached + * messages array. + */ + getCachedMessages: function(messageTypes) { + if (!messageTypes) { + return { + error: "missingParameter", + message: "The messageTypes parameter is missing.", + }; + } + + const messages = []; + + const consoleServiceCachedMessages = + messageTypes.includes("PageError") || messageTypes.includes("LogMessage") + ? this.consoleServiceListener?.getCachedMessages( + !this.parentActor.isRootActor + ) + : null; + + for (const type of messageTypes) { + switch (type) { + case "ConsoleAPI": { + if (!this.consoleAPIListener) { + break; + } + + // this.global might not be a window (can be a worker global or a Sandbox), + // and in such case performance isn't defined + const winStartTime = this.global?.performance?.timing + ?.navigationStart; + + const cache = this.consoleAPIListener.getCachedMessages( + !this.parentActor.isRootActor + ); + cache.forEach(cachedMessage => { + // Filter out messages that came from a ServiceWorker but happened + // before the page was requested. + if ( + cachedMessage.innerID === "ServiceWorker" && + winStartTime > cachedMessage.timeStamp + ) { + return; + } + + messages.push({ + message: this.prepareConsoleMessageForRemote(cachedMessage), + type: "consoleAPICall", + }); + }); + break; + } + + case "PageError": { + if (!consoleServiceCachedMessages) { + break; + } + + for (const cachedMessage of consoleServiceCachedMessages) { + if (!(cachedMessage instanceof Ci.nsIScriptError)) { + continue; + } + + messages.push({ + pageError: this.preparePageErrorForRemote(cachedMessage), + type: "pageError", + }); + } + break; + } + + case "LogMessage": { + if (!consoleServiceCachedMessages) { + break; + } + + for (const cachedMessage of consoleServiceCachedMessages) { + if (cachedMessage instanceof Ci.nsIScriptError) { + continue; + } + + messages.push({ + message: this._createStringGrip(cachedMessage.message), + timeStamp: cachedMessage.timeStamp, + type: "logMessage", + }); + } + break; + } + } + } + + return { + messages: messages, + }; + }, + + /** + * Handler for the "evaluateJSAsync" request. This method evaluates a given + * JavaScript string with an associated `resultID`. + * + * The result will be returned later as an unsolicited `evaluationResult`, + * that can be associated back to this request via the `resultID` field. + * + * @param object request + * The JSON request object received from the Web Console client. + * @return object + * The response packet to send to with the unique id in the + * `resultID` field. + */ + evaluateJSAsync: async function(request) { + const startTime = Date.now(); + // Use Date instead of UUID as this code is used by workers, which + // don't have access to the UUID XPCOM component. + // Also use a counter in order to prevent mixing up response when calling + // evaluateJSAsync during the same millisecond. + const resultID = startTime + "-" + this._evalCounter++; + + // Execute the evaluation in the next event loop in order to immediately + // reply with the resultID. + DevToolsUtils.executeSoon(async () => { + try { + // Execute the script that may pause. + let response = await this.evaluateJS(request); + // Wait for any potential returned Promise. + response = await this._maybeWaitForResponseResult(response); + // Set the timestamp only now, so any messages logged in the expression will come + // before the result. Add an extra millisecond so the result has a different timestamp + // than the console message it might have emitted (unlike the evaluation result, + // console api messages are throttled before being handled by the webconsole client, + // which might cause some ordering issue). + response.timestamp = Date.now() + 1; + // Finally, emit an unsolicited evaluationResult packet with the evaluation result. + this.emit("evaluationResult", { + type: "evaluationResult", + resultID, + startTime, + ...response, + }); + return; + } catch (e) { + const message = `Encountered error while waiting for Helper Result: ${e}\n${e.stack}`; + DevToolsUtils.reportException("evaluateJSAsync", Error(message)); + } + }); + return { resultID }; + }, + + /** + * In order to have asynchronous commands (e.g. screenshot, top-level await, …) , + * we have to be able to handle promises. This method handles waiting for the promise, + * and then returns the result. + * + * @private + * @param object response + * The response packet to send to with the unique id in the + * `resultID` field, and potentially a promise in the `helperResult` or in the + * `awaitResult` field. + * + * @return object + * The updated response object. + */ + _maybeWaitForResponseResult: async function(response) { + if (!response) { + return response; + } + + const thenable = obj => obj && typeof obj.then === "function"; + const waitForHelperResult = + response.helperResult && thenable(response.helperResult); + const waitForAwaitResult = + response.awaitResult && thenable(response.awaitResult); + + if (!waitForAwaitResult && !waitForHelperResult) { + return response; + } + + // Wait for asynchronous command completion before sending back the response + if (waitForHelperResult) { + response.helperResult = await response.helperResult; + } else if (waitForAwaitResult) { + let result; + try { + result = await response.awaitResult; + + // `createValueGrip` expect a debuggee value, while here we have the raw object. + // We need to call `makeDebuggeeValue` on it to make it work. + const dbgResult = this.makeDebuggeeValue(result); + response.result = this.createValueGrip(dbgResult); + } catch (e) { + // The promise was rejected. We let the engine handle this as it will report a + // `uncaught exception` error. + response.topLevelAwaitRejected = true; + } + + // Remove the promise from the response object. + delete response.awaitResult; + } + + return response; + }, + + /** + * Handler for the "evaluateJS" request. This method evaluates the given + * JavaScript string and sends back the result. + * + * @param object request + * The JSON request object received from the Web Console client. + * @return object + * The evaluation response packet. + */ + evaluateJS: function(request) { + const input = request.text; + + const evalOptions = { + frameActor: request.frameActor, + url: request.url, + innerWindowID: request.innerWindowID, + selectedNodeActor: request.selectedNodeActor, + selectedObjectActor: request.selectedObjectActor, + eager: request.eager, + bindings: request.bindings, + lineNumber: request.lineNumber, + }; + + const { mapped } = request; + + // Set a flag on the thread actor which indicates an evaluation is being + // done for the client. This can affect how debugger handlers behave. + this.parentActor.threadActor.insideClientEvaluation = evalOptions; + + const evalInfo = evalWithDebugger(input, evalOptions, this); + + this.parentActor.threadActor.insideClientEvaluation = null; + + return new Promise((resolve, reject) => { + // Queue up a task to run in the next tick so any microtask created by the evaluated + // expression has the time to be run. + // e.g. in : + // ``` + // const promiseThenCb = result => "result: " + result; + // new Promise(res => res("hello")).then(promiseThenCb) + // ``` + // we want`promiseThenCb` to have run before handling the result. + DevToolsUtils.executeSoon(() => { + try { + const result = this.prepareEvaluationResult( + evalInfo, + input, + request.eager, + mapped + ); + resolve(result); + } catch (err) { + reject(err); + } + }); + }); + }, + + // eslint-disable-next-line complexity + prepareEvaluationResult: function(evalInfo, input, eager, mapped) { + const evalResult = evalInfo.result; + const helperResult = evalInfo.helperResult; + + let result, + errorDocURL, + errorMessage, + errorNotes = null, + errorGrip = null, + frame = null, + awaitResult, + errorMessageName, + exceptionStack; + if (evalResult) { + if ("return" in evalResult) { + result = evalResult.return; + if ( + mapped?.await && + result && + result.class === "Promise" && + typeof result.unsafeDereference === "function" + ) { + awaitResult = result.unsafeDereference(); + } + } else if ("yield" in evalResult) { + result = evalResult.yield; + } else if ("throw" in evalResult) { + const error = evalResult.throw; + errorGrip = this.createValueGrip(error); + + exceptionStack = this.prepareStackForRemote(evalResult.stack); + + if (exceptionStack) { + // Set the frame based on the topmost stack frame for the exception. + const { + filename: source, + sourceId, + lineNumber: line, + columnNumber: column, + } = exceptionStack[0]; + frame = { source, sourceId, line, column }; + + exceptionStack = WebConsoleUtils.removeFramesAboveDebuggerEval( + exceptionStack + ); + } + + errorMessage = String(error); + if (typeof error === "object" && error !== null) { + try { + errorMessage = DevToolsUtils.callPropertyOnObject( + error, + "toString" + ); + } catch (e) { + // If the debuggee is not allowed to access the "toString" property + // of the error object, calling this property from the debuggee's + // compartment will fail. The debugger should show the error object + // as it is seen by the debuggee, so this behavior is correct. + // + // Unfortunately, we have at least one test that assumes calling the + // "toString" property of an error object will succeed if the + // debugger is allowed to access it, regardless of whether the + // debuggee is allowed to access it or not. + // + // To accomodate these tests, if calling the "toString" property + // from the debuggee compartment fails, we rewrap the error object + // in the debugger's compartment, and then call the "toString" + // property from there. + if (typeof error.unsafeDereference === "function") { + const rawError = error.unsafeDereference(); + errorMessage = rawError ? rawError.toString() : ""; + } + } + } + + // It is possible that we won't have permission to unwrap an + // object and retrieve its errorMessageName. + try { + errorDocURL = ErrorDocs.GetURL(error); + errorMessageName = error.errorMessageName; + } catch (ex) { + // ignored + } + + try { + const line = error.errorLineNumber; + const column = error.errorColumnNumber; + + if (typeof line === "number" && typeof column === "number") { + // Set frame only if we have line/column numbers. + frame = { + source: "debugger eval code", + line, + column, + }; + } + } catch (ex) { + // ignored + } + + try { + const notes = error.errorNotes; + if (notes?.length) { + errorNotes = []; + for (const note of notes) { + errorNotes.push({ + messageBody: this._createStringGrip(note.message), + frame: { + source: note.fileName, + line: note.lineNumber, + column: note.columnNumber, + }, + }); + } + } + } catch (ex) { + // ignored + } + } + } + + // If a value is encountered that the devtools server doesn't support yet, + // the console should remain functional. + let resultGrip; + if (!awaitResult) { + try { + const objectActor = this.parentActor.threadActor.getThreadLifetimeObject( + result + ); + if (objectActor) { + resultGrip = this.parentActor.threadActor.createValueGrip(result); + } else { + resultGrip = this.createValueGrip(result); + } + } catch (e) { + errorMessage = e; + } + } + + // Don't update _lastConsoleInputEvaluation in eager evaluation, as it would interfere + // with the $_ command. + if (!eager) { + if (!awaitResult) { + this._lastConsoleInputEvaluation = result; + } else { + // If we evaluated a top-level await expression, we want to assign its result to the + // _lastConsoleInputEvaluation only when the promise resolves, and only if it + // resolves. If the promise rejects, we don't re-assign _lastConsoleInputEvaluation, + // it will keep its previous value. + + const p = awaitResult.then(res => { + this._lastConsoleInputEvaluation = this.makeDebuggeeValue(res); + }); + + // If the top level await was already rejected (e.g. `await Promise.reject("bleh")`), + // catch the resulting promise of awaitResult.then. + // If we don't do that, the new Promise will also be rejected, and since it's + // unhandled, it will generate an error. + // We don't want to do that for pending promise (e.g. `await new Promise((res, rej) => setTimeout(rej,250))`), + // as the the Promise rejection will be considered as handled, and the "Uncaught (in promise)" + // message wouldn't be emitted. + const { state } = ObjectUtils.getPromiseState(evalResult.return); + if (state === "rejected") { + p.catch(() => {}); + } + } + } + + return { + input: input, + result: resultGrip, + awaitResult, + exception: errorGrip, + exceptionMessage: this._createStringGrip(errorMessage), + exceptionDocURL: errorDocURL, + exceptionStack, + hasException: errorGrip !== null, + errorMessageName, + frame, + helperResult: helperResult, + notes: errorNotes, + }; + }, + + /** + * The Autocomplete request handler. + * + * @param string text + * The request message - what input to autocomplete. + * @param number cursor + * The cursor position at the moment of starting autocomplete. + * @param string frameActor + * The frameactor id of the current paused frame. + * @param string selectedNodeActor + * The actor id of the currently selected node. + * @param array authorizedEvaluations + * Array of the properties access which can be executed by the engine. + * @return object + * The response message - matched properties. + */ + autocomplete: function( + text, + cursor, + frameActorId, + selectedNodeActor, + authorizedEvaluations, + expressionVars = [] + ) { + let dbgObject = null; + let environment = null; + let matches = []; + let matchProp; + let isElementAccess; + + const reqText = text.substr(0, cursor); + + if (isCommand(reqText)) { + const commandsCache = this._getWebConsoleCommandsCache(); + matchProp = reqText; + matches = validCommands + .filter( + c => + `:${c}`.startsWith(reqText) && + commandsCache.find(n => `:${n}`.startsWith(reqText)) + ) + .map(c => `:${c}`); + } else { + // This is the case of the paused debugger + if (frameActorId) { + const frameActor = this.conn.getActor(frameActorId); + try { + // Need to try/catch since accessing frame.environment + // can throw "Debugger.Frame is not live" + const frame = frameActor.frame; + environment = frame.environment; + } catch (e) { + DevToolsUtils.reportException( + "autocomplete", + Error("The frame actor was not found: " + frameActorId) + ); + } + } else { + dbgObject = this.dbg.addDebuggee(this.evalGlobal); + } + + const result = JSPropertyProvider({ + dbgObject, + environment, + inputValue: text, + cursor, + webconsoleActor: this, + selectedNodeActor, + authorizedEvaluations, + expressionVars, + }); + + if (result === null) { + return { + matches: null, + }; + } + + if (result && result.isUnsafeGetter === true) { + return { + isUnsafeGetter: true, + getterPath: result.getterPath, + }; + } + + matches = result.matches || new Set(); + matchProp = result.matchProp || ""; + isElementAccess = result.isElementAccess; + + // We consider '$' as alphanumeric because it is used in the names of some + // helper functions; we also consider whitespace as alphanum since it should not + // be seen as break in the evaled string. + const lastNonAlphaIsDot = /[.][a-zA-Z0-9$\s]*$/.test(reqText); + + // We only return commands and keywords when we are not dealing with a property or + // element access. + if (matchProp && !lastNonAlphaIsDot && !isElementAccess) { + this._getWebConsoleCommandsCache().forEach(n => { + // filter out `screenshot` command as it is inaccessible without the `:` prefix + if (n !== "screenshot" && n.startsWith(result.matchProp)) { + matches.add(n); + } + }); + + for (const keyword of RESERVED_JS_KEYWORDS) { + if (keyword.startsWith(result.matchProp)) { + matches.add(keyword); + } + } + } + + // Sort the results in order to display lowercased item first (e.g. we want to + // display `document` then `Document` as we loosely match the user input if the + // first letter was lowercase). + const firstMeaningfulCharIndex = isElementAccess ? 1 : 0; + matches = Array.from(matches).sort((a, b) => { + const aFirstMeaningfulChar = a[firstMeaningfulCharIndex]; + const bFirstMeaningfulChar = b[firstMeaningfulCharIndex]; + const lA = + aFirstMeaningfulChar.toLocaleLowerCase() === aFirstMeaningfulChar; + const lB = + bFirstMeaningfulChar.toLocaleLowerCase() === bFirstMeaningfulChar; + if (lA === lB) { + if (a === matchProp) { + return -1; + } + if (b === matchProp) { + return 1; + } + return a.localeCompare(b); + } + return lA ? -1 : 1; + }); + } + + return { + matches, + matchProp, + isElementAccess: isElementAccess === true, + }; + }, + + /** + * The "clearMessagesCache" request handler. + */ + clearMessagesCache: function() { + if (isWorker) { + // At the moment there is no mechanism available to clear the Console API cache for + // a given worker target (See https://bugzilla.mozilla.org/show_bug.cgi?id=1674336). + // Worker messages from the console service (e.g. error) are emitted from the main + // thread, so this cache will be cleared when the associated document target cache + // is cleared. + return; + } + + const windowId = !this.parentActor.isRootActor + ? WebConsoleUtils.getInnerWindowId(this.global) + : null; + + const ConsoleAPIStorage = Cc[ + "@mozilla.org/consoleAPI-storage;1" + ].getService(Ci.nsIConsoleAPIStorage); + ConsoleAPIStorage.clearEvents(windowId); + + CONSOLE_WORKER_IDS.forEach(id => { + ConsoleAPIStorage.clearEvents(id); + }); + + if (this.parentActor.isRootActor || !this.global) { + // If were dealing with the root actor (e.g. the browser console), we want + // to remove all cached messages, not only the ones specific to a window. + Services.console.reset(); + } else { + WebConsoleUtils.getInnerWindowIDsForFrames(this.global).forEach(id => + Services.console.resetWindow(id) + ); + } + }, + + /** + * The "getPreferences" request handler. + * + * @param array preferences + * The preferences that need to be retrieved. + * @return object + * The response message - a { key: value } object map. + */ + getPreferences: function(preferences) { + const prefs = Object.create(null); + for (const key of preferences) { + prefs[key] = this._prefs[key]; + } + return { preferences: prefs }; + }, + + /** + * The "setPreferences" request handler. + * + * @param object preferences + * The preferences that need to be updated. + */ + setPreferences: function(preferences) { + for (const key in preferences) { + this._prefs[key] = preferences[key]; + + if (this.netmonitors) { + if (key == "NetworkMonitor.saveRequestAndResponseBodies") { + for (const { messageManager } of this.netmonitors) { + messageManager.sendAsyncMessage("debug:netmonitor-preference", { + saveRequestAndResponseBodies: this._prefs[key], + }); + } + } else if (key == "NetworkMonitor.throttleData") { + for (const { messageManager } of this.netmonitors) { + messageManager.sendAsyncMessage("debug:netmonitor-preference", { + throttleData: this._prefs[key], + }); + } + } + } + } + return { updated: Object.keys(preferences) }; + }, + + // End of request handlers. + + /** + * Create an object with the API we expose to the Web Console during + * JavaScript evaluation. + * This object inherits properties and methods from the Web Console actor. + * + * @private + * @param object debuggerGlobal + * A Debugger.Object that wraps a content global. This is used for the + * Web Console Commands. + * @return object + * The same object as |this|, but with an added |sandbox| property. + * The sandbox holds methods and properties that can be used as + * bindings during JS evaluation. + */ + _getWebConsoleCommands: function(debuggerGlobal) { + const helpers = { + window: this.evalGlobal, + makeDebuggeeValue: debuggerGlobal.makeDebuggeeValue.bind(debuggerGlobal), + createValueGrip: this.createValueGrip.bind(this), + preprocessDebuggerObject: this.preprocessDebuggerObject.bind(this), + sandbox: Object.create(null), + helperResult: null, + consoleActor: this, + }; + addWebConsoleCommands(helpers); + + const evalGlobal = this.evalGlobal; + function maybeExport(obj, name) { + if (typeof obj[name] != "function") { + return; + } + + // By default, chrome-implemented functions that are exposed to content + // refuse to accept arguments that are cross-origin for the caller. This + // is generally the safe thing, but causes problems for certain console + // helpers like cd(), where we users sometimes want to pass a cross-origin + // window. To circumvent this restriction, we use exportFunction along + // with a special option designed for this purpose. See bug 1051224. + obj[name] = Cu.exportFunction(obj[name], evalGlobal, { + allowCrossOriginArguments: true, + }); + } + for (const name in helpers.sandbox) { + const desc = Object.getOwnPropertyDescriptor(helpers.sandbox, name); + + // Workers don't have access to Cu so won't be able to exportFunction. + if (!isWorker) { + maybeExport(desc, "get"); + maybeExport(desc, "set"); + maybeExport(desc, "value"); + } + if (desc.value) { + // Make sure the helpers can be used during eval. + desc.value = debuggerGlobal.makeDebuggeeValue(desc.value); + } + Object.defineProperty(helpers.sandbox, name, desc); + } + return helpers; + }, + + _getWebConsoleCommandsCache: function() { + if (!this._webConsoleCommandsCache) { + const helpers = { + sandbox: Object.create(null), + }; + addWebConsoleCommands(helpers); + this._webConsoleCommandsCache = Object.getOwnPropertyNames( + helpers.sandbox + ); + } + return this._webConsoleCommandsCache; + }, + + // Event handlers for various listeners. + + /** + * Handler for messages received from the ConsoleServiceListener. This method + * sends the nsIConsoleMessage to the remote Web Console client. + * + * @param nsIConsoleMessage message + * The message we need to send to the client. + */ + onConsoleServiceMessage: function(message) { + if (message instanceof Ci.nsIScriptError) { + this.emit("pageError", { + pageError: this.preparePageErrorForRemote(message), + }); + } else { + this.emit("logMessage", { + message: this._createStringGrip(message.message), + timeStamp: message.timeStamp, + }); + } + }, + + getActorIdForInternalSourceId(id) { + const actor = this.parentActor.sourcesManager.getSourceActorByInternalSourceId( + id + ); + return actor ? actor.actorID : null; + }, + + /** + * Prepare a SavedFrame stack to be sent to the client. + * + * @param SavedFrame errorStack + * Stack for an error we need to send to the client. + * @return object + * The object you can send to the remote client. + */ + prepareStackForRemote(errorStack) { + // Convert stack objects to the JSON attributes expected by client code + // Bug 1348885: If the global from which this error came from has been + // nuked, stack is going to be a dead wrapper. + if (!errorStack || (Cu && Cu.isDeadWrapper(errorStack))) { + return null; + } + const stack = []; + let s = errorStack; + while (s) { + stack.push({ + filename: s.source, + sourceId: this.getActorIdForInternalSourceId(s.sourceId), + lineNumber: s.line, + columnNumber: s.column, + functionName: s.functionDisplayName, + asyncCause: s.asyncCause ? s.asyncCause : undefined, + }); + s = s.parent || s.asyncParent; + } + return stack; + }, + + /** + * Prepare an nsIScriptError to be sent to the client. + * + * @param nsIScriptError pageError + * The page error we need to send to the client. + * @return object + * The object you can send to the remote client. + */ + preparePageErrorForRemote: function(pageError) { + const stack = this.prepareStackForRemote(pageError.stack); + let lineText = pageError.sourceLine; + if ( + lineText && + lineText.length > DevToolsServer.LONG_STRING_INITIAL_LENGTH + ) { + lineText = lineText.substr(0, DevToolsServer.LONG_STRING_INITIAL_LENGTH); + } + + let notesArray = null; + const notes = pageError.notes; + if (notes?.length) { + notesArray = []; + for (let i = 0, len = notes.length; i < len; i++) { + const note = notes.queryElementAt(i, Ci.nsIScriptErrorNote); + notesArray.push({ + messageBody: this._createStringGrip(note.errorMessage), + frame: { + source: note.sourceName, + sourceId: this.getActorIdForInternalSourceId(note.sourceId), + line: note.lineNumber, + column: note.columnNumber, + }, + }); + } + } + + // If there is no location information in the error but we have a stack, + // fill in the location with the first frame on the stack. + let { sourceName, sourceId, lineNumber, columnNumber } = pageError; + if (!sourceName && !sourceId && !lineNumber && !columnNumber && stack) { + sourceName = stack[0].filename; + sourceId = stack[0].sourceId; + lineNumber = stack[0].lineNumber; + columnNumber = stack[0].columnNumber; + } + + const isCSSMessage = pageError.category === MESSAGE_CATEGORY.CSS_PARSER; + + const result = { + errorMessage: this._createStringGrip(pageError.errorMessage), + errorMessageName: isCSSMessage ? undefined : pageError.errorMessageName, + exceptionDocURL: ErrorDocs.GetURL(pageError), + sourceName, + sourceId: this.getActorIdForInternalSourceId(sourceId), + lineText, + lineNumber, + columnNumber, + category: pageError.category, + innerWindowID: pageError.innerWindowID, + timeStamp: pageError.timeStamp, + warning: !!(pageError.flags & pageError.warningFlag), + error: !(pageError.flags & (pageError.warningFlag | pageError.infoFlag)), + info: !!(pageError.flags & pageError.infoFlag), + private: pageError.isFromPrivateWindow, + stacktrace: stack, + notes: notesArray, + chromeContext: pageError.isFromChromeContext, + isPromiseRejection: isCSSMessage + ? undefined + : pageError.isPromiseRejection, + isForwardedFromContentProcess: pageError.isForwardedFromContentProcess, + cssSelectors: isCSSMessage ? pageError.cssSelectors : undefined, + }; + + // If the pageError does have an exception object, we want to return the grip for it, + // but only if we do manage to get the grip, as we're checking the property on the + // client to render things differently. + if (pageError.hasException) { + try { + const obj = this.makeDebuggeeValue(pageError.exception, true); + if (obj?.class !== "DeadObject") { + result.exception = this.createValueGrip(obj); + result.hasException = true; + } + } catch (e) {} + } + + return result; + }, + + /** + * Handler for window.console API calls received from the ConsoleAPIListener. + * This method sends the object to the remote Web Console client. + * + * @see ConsoleAPIListener + * @param object message + * The console API call we need to send to the remote client. + */ + onConsoleAPICall: function(message) { + this.emit("consoleAPICall", { + message: this.prepareConsoleMessageForRemote(message), + }); + }, + + /** + * Handler for the DocumentEventsListener. + * + * @see DocumentEventsListener + * @param {String} name + * The document event name that either of followings. + * - dom-loading + * - dom-interactive + * - dom-complete + * @param {Number} time + * The time that the event is fired. + */ + onDocumentEvent: function(name, time) { + this.emit("documentEvent", { + name, + time, + }); + }, + + /** + * Send a new HTTP request from the target's window. + * + * @param object request + * The details of the HTTP request. + */ + async sendHTTPRequest(request) { + const { url, method, headers, body, cause } = request; + // Set the loadingNode and loadGroup to the target document - otherwise the + // request won't show up in the opened netmonitor. + const doc = this.global.document; + + const channel = NetUtil.newChannel({ + uri: NetUtil.newURI(url), + loadingNode: doc, + securityFlags: Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL, + contentPolicyType: + stringToCauseType(cause.type) || Ci.nsIContentPolicy.TYPE_OTHER, + }); + + channel.QueryInterface(Ci.nsIHttpChannel); + + channel.loadGroup = doc.documentLoadGroup; + channel.loadFlags |= + Ci.nsIRequest.LOAD_BYPASS_CACHE | + Ci.nsIRequest.INHIBIT_CACHING | + Ci.nsIRequest.LOAD_ANONYMOUS; + + channel.requestMethod = method; + if (headers) { + for (const { name, value } of headers) { + if (name.toLowerCase() == "referer") { + // The referer header and referrerInfo object should always match. So + // if we want to set the header from privileged context, we should set + // referrerInfo. The referrer header will get set internally. + channel.setNewReferrerInfo( + value, + Ci.nsIReferrerInfo.UNSAFE_URL, + true + ); + } else { + channel.setRequestHeader(name, value, false); + } + } + } + + if (body) { + channel.QueryInterface(Ci.nsIUploadChannel2); + const bodyStream = Cc[ + "@mozilla.org/io/string-input-stream;1" + ].createInstance(Ci.nsIStringInputStream); + bodyStream.setData(body, body.length); + channel.explicitSetUploadStream(bodyStream, null, -1, method, false); + } + + NetUtil.asyncFetch(channel, () => {}); + + if (!this.netmonitors) { + return null; + } + const { channelId } = channel; + // Only query the NetworkMonitorActor running in the parent process, where the + // request will be done. There always is one listener running in the parent process, + // see startListeners. + const netmonitor = this.netmonitors.filter( + ({ parentProcess }) => parentProcess + )[0]; + const { messageManager } = netmonitor; + return new Promise(resolve => { + const onMessage = ({ data }) => { + if (data.channelId == channelId) { + messageManager.removeMessageListener( + "debug:get-network-event-actor:response", + onMessage + ); + resolve({ + eventActor: data.actor, + }); + } + }; + messageManager.addMessageListener( + "debug:get-network-event-actor:response", + onMessage + ); + messageManager.sendAsyncMessage("debug:get-network-event-actor:request", { + channelId, + }); + }); + }, + + /** + * Send a message to all the netmonitor message managers, and resolve when + * all of them replied with the expected responseName message. + * + * @param {String} messageName + * Name of the message to send via the netmonitor message managers. + * @param {String} responseName + * Name of the message that should be received when the message has + * been processed by the netmonitor instance. + * @param {Object} args + * argument object passed with the initial message. + */ + async _sendMessageToNetmonitors(messageName, responseName, args) { + if (!this.netmonitors) { + return null; + } + const results = await Promise.all( + this.netmonitors.map(({ messageManager }) => { + const onResponseReceived = new Promise(resolve => { + messageManager.addMessageListener(responseName, function onResponse( + response + ) { + messageManager.removeMessageListener(responseName, onResponse); + resolve(response); + }); + }); + messageManager.sendAsyncMessage(messageName, args); + return onResponseReceived; + }) + ); + + return results; + }, + + /** + * Block a request based on certain filtering options. + * + * Currently, an exact URL match is the only supported filter type. + * In the future, there may be other types of filters, such as domain. + * For now, ignore anything other than URL. + * + * @param object filter + * An object containing a `url` key with a URL to block. + */ + async blockRequest(filter) { + await this._sendMessageToNetmonitors( + "debug:block-request", + "debug:block-request:response", + { filter } + ); + + return {}; + }, + + /** + * Unblock a request based on certain filtering options. + * + * Currently, an exact URL match is the only supported filter type. + * In the future, there may be other types of filters, such as domain. + * For now, ignore anything other than URL. + * + * @param object filter + * An object containing a `url` key with a URL to unblock. + */ + async unblockRequest(filter) { + await this._sendMessageToNetmonitors( + "debug:unblock-request", + "debug:unblock-request:response", + { filter } + ); + + return {}; + }, + + /* + * Gets the list of blocked request urls as per the backend + */ + async getBlockedUrls() { + const responses = + (await this._sendMessageToNetmonitors( + "debug:get-blocked-urls", + "debug:get-blocked-urls:response" + )) || []; + if (!responses || responses.length == 0) { + return []; + } + + return Array.from( + new Set( + responses + .filter(response => response.data) + .map(response => response.data) + ) + ); + }, + + /** + * Sets the list of blocked request URLs as provided by the netmonitor frontend + * + * This match will be a (String).includes match, not an exact URL match + * + * @param object filter + * An object containing a `url` key with a URL to unblock. + */ + async setBlockedUrls(urls) { + await this._sendMessageToNetmonitors( + "debug:set-blocked-urls", + "debug:set-blocked-urls:response", + { urls } + ); + + return {}; + }, + + /** + * Handler for file activity. This method sends the file request information + * to the remote Web Console client. + * + * @see ConsoleFileActivityListener + * @param string fileURI + * The requested file URI. + */ + onFileActivity: function(fileURI) { + this.emit("fileActivity", { + uri: fileURI, + }); + }, + + // End of event handlers for various listeners. + + /** + * Prepare a message from the console API to be sent to the remote Web Console + * instance. + * + * @param object message + * The original message received from console-api-log-event. + * @param boolean aUseObjectGlobal + * If |true| the object global is determined and added as a debuggee, + * otherwise |this.global| is used when makeDebuggeeValue() is invoked. + * @return object + * The object that can be sent to the remote client. + */ + prepareConsoleMessageForRemote: function(message, useObjectGlobal = true) { + const result = WebConsoleUtils.cloneObject(message); + + result.workerType = WebConsoleUtils.getWorkerType(result) || "none"; + result.sourceId = this.getActorIdForInternalSourceId(result.sourceId); + + delete result.wrappedJSObject; + delete result.ID; + delete result.innerID; + delete result.consoleID; + + if (result.stacktrace) { + result.stacktrace = result.stacktrace.map(frame => { + return { + ...frame, + sourceId: this.getActorIdForInternalSourceId(frame.sourceId), + }; + }); + } + + result.arguments = (message.arguments || []).map(obj => { + const dbgObj = this.makeDebuggeeValue(obj, useObjectGlobal); + return this.createValueGrip(dbgObj); + }); + + result.styles = (message.styles || []).map(string => { + return this.createValueGrip(string); + }); + + if (result.level === "table") { + const tableItems = this._getConsoleTableMessageItems(result); + if (tableItems) { + result.arguments[0].ownProperties = tableItems; + result.arguments[0].preview = null; + } + + // Only return the 2 first params. + result.arguments = result.arguments.slice(0, 2); + } + + result.category = message.category || "webdev"; + result.innerWindowID = message.innerID; + + return result; + }, + + /** + * Return the properties needed to display the appropriate table for a given + * console.table call. + * This function does a little more than creating an ObjectActor for the first + * parameter of the message. When layout out the console table in the output, we want + * to be able to look into sub-properties so the table can have a different layout ( + * for arrays of arrays, objects with objects properties, arrays of objects, …). + * So here we need to retrieve the properties of the first parameter, and also all the + * sub-properties we might need. + * + * @param {Object} result: The console.table message. + * @returns {Object} An object containing the properties of the first argument of the + * console.table call. + */ + _getConsoleTableMessageItems: function(result) { + if ( + !result || + !Array.isArray(result.arguments) || + result.arguments.length == 0 + ) { + return null; + } + + const [tableItemGrip] = result.arguments; + const dataType = tableItemGrip.class; + const needEntries = ["Map", "WeakMap", "Set", "WeakSet"].includes(dataType); + const ignoreNonIndexedProperties = isArray(tableItemGrip); + + const tableItemActor = this.getActorByID(tableItemGrip.actor); + if (!tableItemActor) { + return null; + } + + // Retrieve the properties (or entries for Set/Map) of the console table first arg. + const iterator = needEntries + ? tableItemActor.enumEntries() + : tableItemActor.enumProperties({ + ignoreNonIndexedProperties, + }); + const { ownProperties } = iterator.all(); + + // The iterator returns a descriptor for each property, wherein the value could be + // in one of those sub-property. + const descriptorKeys = ["safeGetterValues", "getterValue", "value"]; + + Object.values(ownProperties).forEach(desc => { + if (typeof desc !== "undefined") { + descriptorKeys.forEach(key => { + if (desc && desc.hasOwnProperty(key)) { + const grip = desc[key]; + + // We need to load sub-properties as well to render the table in a nice way. + const actor = grip && this.getActorByID(grip.actor); + if (actor) { + const res = actor + .enumProperties({ + ignoreNonIndexedProperties: isArray(grip), + }) + .all(); + if (res?.ownProperties) { + desc[key].ownProperties = res.ownProperties; + } + } + } + }); + } + }); + + return ownProperties; + }, + + /** + * Notification observer for the "last-pb-context-exited" topic. + * + * @private + * @param object subject + * Notification subject - in this case it is the inner window ID that + * was destroyed. + * @param string topic + * Notification topic. + */ + _onObserverNotification: function(subject, topic) { + if (topic === "last-pb-context-exited") { + this.emit("lastPrivateContextExited"); + } + }, + + /** + * The "will-navigate" progress listener. This is used to clear the current + * eval scope. + */ + _onWillNavigate: function({ window, isTopLevel }) { + if (isTopLevel) { + this._evalGlobal = null; + EventEmitter.off(this.parentActor, "will-navigate", this._onWillNavigate); + this._progressListenerActive = false; + } + }, + + /** + * This listener is called when we switch to another frame, + * mostly to unregister previous listeners and start listening on the new document. + */ + _onChangedToplevelDocument: function() { + // Convert the Set to an Array + const listeners = [...this._listeners]; + + // Unregister existing listener on the previous document + // (pass a copy of the array as it will shift from it) + this.stopListeners(listeners.slice()); + + // This method is called after this.global is changed, + // so we register new listener on this new global + this.startListeners(listeners); + + // Also reset the cached top level chrome window being targeted + this._lastChromeWindow = null; + }, +}); + +exports.WebConsoleActor = WebConsoleActor; |