summaryrefslogtreecommitdiffstats
path: root/devtools/server/actors/webconsole
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--devtools/server/actors/webconsole.js1722
-rw-r--r--devtools/server/actors/webconsole/commands/manager.js577
-rw-r--r--devtools/server/actors/webconsole/commands/moz.build10
-rw-r--r--devtools/server/actors/webconsole/commands/parser.js249
-rw-r--r--devtools/server/actors/webconsole/eager-ecma-allowlist.js248
-rw-r--r--devtools/server/actors/webconsole/eager-function-allowlist.js52
-rw-r--r--devtools/server/actors/webconsole/eval-with-debugger.js618
-rw-r--r--devtools/server/actors/webconsole/listeners/console-api.js255
-rw-r--r--devtools/server/actors/webconsole/listeners/console-file-activity.js126
-rw-r--r--devtools/server/actors/webconsole/listeners/console-reflow.js90
-rw-r--r--devtools/server/actors/webconsole/listeners/console-service.js193
-rw-r--r--devtools/server/actors/webconsole/listeners/document-events.js247
-rw-r--r--devtools/server/actors/webconsole/listeners/moz.build13
-rw-r--r--devtools/server/actors/webconsole/moz.build20
-rw-r--r--devtools/server/actors/webconsole/utils.js160
-rw-r--r--devtools/server/actors/webconsole/webidl-pure-allowlist.js87
-rw-r--r--devtools/server/actors/webconsole/webidl-unsafe-getters-names.js20
-rw-r--r--devtools/server/actors/webconsole/worker-listeners.js35
18 files changed, 4722 insertions, 0 deletions
diff --git a/devtools/server/actors/webconsole.js b/devtools/server/actors/webconsole.js
new file mode 100644
index 0000000000..b3a27cce02
--- /dev/null
+++ b/devtools/server/actors/webconsole.js
@@ -0,0 +1,1722 @@
+/* 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/. */
+
+/* global clearConsoleEvents */
+
+"use strict";
+
+const { Actor } = require("resource://devtools/shared/protocol.js");
+const {
+ webconsoleSpec,
+} = require("resource://devtools/shared/specs/webconsole.js");
+
+const {
+ DevToolsServer,
+} = require("resource://devtools/server/devtools-server.js");
+const { ThreadActor } = require("resource://devtools/server/actors/thread.js");
+const { ObjectActor } = require("resource://devtools/server/actors/object.js");
+const {
+ LongStringActor,
+} = require("resource://devtools/server/actors/string.js");
+const {
+ createValueGrip,
+ isArray,
+ stringIsLong,
+} = require("resource://devtools/server/actors/object/utils.js");
+const DevToolsUtils = require("resource://devtools/shared/DevToolsUtils.js");
+const ErrorDocs = require("resource://devtools/server/actors/errordocs.js");
+const Targets = require("resource://devtools/server/actors/targets/index.js");
+
+loader.lazyRequireGetter(
+ this,
+ "evalWithDebugger",
+ "resource://devtools/server/actors/webconsole/eval-with-debugger.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "ConsoleFileActivityListener",
+ "resource://devtools/server/actors/webconsole/listeners/console-file-activity.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "JSPropertyProvider",
+ "resource://devtools/shared/webconsole/js-property-provider.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ ["isCommand"],
+ "resource://devtools/server/actors/webconsole/commands/parser.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ ["CONSOLE_WORKER_IDS", "WebConsoleUtils"],
+ "resource://devtools/server/actors/webconsole/utils.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ ["WebConsoleCommandsManager"],
+ "resource://devtools/server/actors/webconsole/commands/manager.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "EnvironmentActor",
+ "resource://devtools/server/actors/environment.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "EventEmitter",
+ "resource://devtools/shared/event-emitter.js"
+);
+loader.lazyRequireGetter(
+ this,
+ "MESSAGE_CATEGORY",
+ "resource://devtools/shared/constants.js",
+ true
+);
+
+// Generated by /devtools/shared/webconsole/GenerateReservedWordsJS.py
+loader.lazyRequireGetter(
+ this,
+ "RESERVED_JS_KEYWORDS",
+ "resource://devtools/shared/webconsole/reserved-js-words.js"
+);
+
+// Overwrite implemented listeners for workers so that we don't attempt
+// to load an unsupported module.
+if (isWorker) {
+ loader.lazyRequireGetter(
+ this,
+ ["ConsoleAPIListener", "ConsoleServiceListener"],
+ "resource://devtools/server/actors/webconsole/worker-listeners.js",
+ true
+ );
+} else {
+ loader.lazyRequireGetter(
+ this,
+ "ConsoleAPIListener",
+ "resource://devtools/server/actors/webconsole/listeners/console-api.js",
+ true
+ );
+ loader.lazyRequireGetter(
+ this,
+ "ConsoleServiceListener",
+ "resource://devtools/server/actors/webconsole/listeners/console-service.js",
+ true
+ );
+ loader.lazyRequireGetter(
+ this,
+ "ConsoleReflowListener",
+ "resource://devtools/server/actors/webconsole/listeners/console-reflow.js",
+ true
+ );
+ loader.lazyRequireGetter(
+ this,
+ "DocumentEventsListener",
+ "resource://devtools/server/actors/webconsole/listeners/document-events.js",
+ true
+ );
+}
+loader.lazyRequireGetter(
+ this,
+ "ObjectUtils",
+ "resource://devtools/server/actors/object/utils.js"
+);
+
+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.
+ */
+class WebConsoleActor extends Actor {
+ constructor(connection, parentActor) {
+ super(connection, webconsoleSpec);
+
+ this.parentActor = parentActor;
+
+ 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
+ );
+ }
+
+ /**
+ * Debugger instance.
+ *
+ * @see jsdebugger.sys.mjs
+ */
+ dbg = null;
+
+ /**
+ * This is used by the ObjectActor to keep track of the depth of grip() calls.
+ * @private
+ * @type number
+ */
+ _gripDepth = null;
+
+ /**
+ * Holds a set of all currently registered listeners.
+ *
+ * @private
+ * @type Set
+ */
+ _listeners = 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.
+ *
+ * (note that is is also used for browser toolbox and webextension
+ * i.e. all targets flagged with isRootActor=true)
+ *
+ * @private
+ * @return nsIDOMWindow
+ * The window to use, or null if no window could be found.
+ */
+ _getWindowForBrowserConsole() {
+ // Check if our last used chrome window is still live.
+ let window = this._lastChromeWindow && this._lastChromeWindow.get();
+ // If not, look for a new one.
+ // In case of WebExtension reload of the background page, the last
+ // chrome window might be a dead wrapper, from which we can't check for window.closed.
+ if (!window || Cu.isDeadWrapper(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(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;
+
+ grip() {
+ return { actor: this.actorID };
+ }
+
+ _findProtoChain = ThreadActor.prototype._findProtoChain;
+ _removeFromProtoChain = ThreadActor.prototype._removeFromProtoChain;
+
+ /**
+ * Destroy the current WebConsoleActor instance.
+ */
+ destroy() {
+ this.stopListeners();
+ super.destroy();
+
+ EventEmitter.off(
+ this.parentActor,
+ "changed-toplevel-document",
+ this._onChangedToplevelDocument
+ );
+
+ this._lastConsoleInputEvaluation = null;
+ this._evalGlobal = null;
+ this.dbg = 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(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(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(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(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(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(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() {
+ 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
+ async startListeners(listeners) {
+ const startedListeners = [];
+ const global = !this.parentActor.isRootActor ? this.global : null;
+ const isTargetActorContentProcess =
+ this.parentActor.targetType === Targets.TYPES.PROCESS;
+
+ 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,
+ {
+ matchExactWindow: this.parentActor.ignoreSubFrames,
+ }
+ );
+ 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,
+ {
+ matchExactWindow: this.parentActor.ignoreSubFrames,
+ ...(this.parentActor.consoleAPIListenerOptions || {}),
+ }
+ );
+ this.consoleAPIListener.init();
+ }
+ startedListeners.push(event);
+ break;
+ case "NetworkActivity":
+ // Workers don't support this message type
+ if (isWorker) {
+ break;
+ }
+ // Bug 1807650 removed this in favor of the new Watcher/Resources APIs
+ const errorMessage =
+ "NetworkActivity is no longer supported. " +
+ "Instead use Watcher actor's watchResources and listen to NETWORK_EVENT resource";
+ dump(errorMessage + "\n");
+ throw new Error(errorMessage);
+ 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 "DocumentEvents":
+ // Workers don't support this message type
+ if (isWorker || isTargetActorContentProcess) {
+ break;
+ }
+ if (!this.documentEventsListener) {
+ this.documentEventsListener = new DocumentEventsListener(
+ this.parentActor
+ );
+
+ this.documentEventsListener.on("dom-loading", data =>
+ this.onDocumentEvent("dom-loading", data)
+ );
+ this.documentEventsListener.on("dom-interactive", data =>
+ this.onDocumentEvent("dom-interactive", data)
+ );
+ this.documentEventsListener.on("dom-complete", data =>
+ this.onDocumentEvent("dom-complete", data)
+ );
+
+ this.documentEventsListener.listen();
+ }
+ startedListeners.push(event);
+ break;
+ }
+ }
+
+ // Update the live list of running listeners
+ startedListeners.forEach(this._listeners.add, this._listeners);
+
+ return {
+ startedListeners,
+ };
+ }
+
+ /**
+ * 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(listeners) {
+ const stoppedListeners = [];
+
+ // If no specific listeners are requested to be detached, we stop all
+ // listeners.
+ const eventsToDetach = listeners || [
+ "PageError",
+ "ConsoleAPI",
+ "FileActivity",
+ "ReflowActivity",
+ "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 "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 "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 };
+ }
+
+ /**
+ * 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(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.microSecondTimeStamp / 1000,
+ type: "logMessage",
+ });
+ }
+ break;
+ }
+ }
+ }
+
+ return {
+ 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.
+ */
+ async evaluateJSAsync(request) {
+ const startTime = ChromeUtils.dateNow();
+ // Use a timestamp instead of a 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
+ // at the exact same time.
+ const resultID = startTime + "-" + this._evalCounter++;
+
+ // Execute the evaluation in the next event loop in order to immediately
+ // reply with the resultID.
+ //
+ // The console input should be evaluated with micro task level != 0,
+ // so that microtask checkpoint isn't performed while evaluating it.
+ DevToolsUtils.executeSoonWithMicroTask(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 (e.g. console.log)
+ // can be appended before the result message (unlike the evaluation result, other
+ // console resources are throttled before being handled by the webconsole client,
+ // which might cause some ordering issue).
+ // Use ChromeUtils.dateNow() as it gives us a higher precision than Date.now().
+ response.timestamp = ChromeUtils.dateNow();
+ // 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 support async evaluations (e.g. 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.
+ */
+ async _maybeWaitForResponseResult(response) {
+ if (!response?.awaitResult) {
+ return response;
+ }
+
+ 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(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(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,
+ result: resultGrip,
+ awaitResult,
+ exception: errorGrip,
+ exceptionMessage: this._createStringGrip(errorMessage),
+ exceptionDocURL: errorDocURL,
+ exceptionStack,
+ hasException: errorGrip !== null,
+ errorMessageName,
+ frame,
+ 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(
+ 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)) {
+ matchProp = reqText;
+ matches = WebConsoleCommandsManager.getAllColonCommandNames()
+ .filter(c => `:${c}`.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,
+ frameActorId,
+ 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) {
+ const colonOnlyCommands =
+ WebConsoleCommandsManager.getColonOnlyCommandNames();
+ for (const name of WebConsoleCommandsManager.getAllCommandNames()) {
+ // Filter out commands like `screenshot` as it is inaccessible without the `:` prefix
+ if (
+ !colonOnlyCommands.includes(name) &&
+ name.startsWith(result.matchProp)
+ ) {
+ matches.add(name);
+ }
+ }
+
+ 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 "clearMessagesCacheAsync" request handler.
+ */
+ clearMessagesCacheAsync() {
+ if (isWorker) {
+ // Defined on WorkerScope
+ clearConsoleEvents();
+ 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 if (this.parentActor.ignoreSubFrames) {
+ Services.console.resetWindow(windowId);
+ } else {
+ WebConsoleUtils.getInnerWindowIDsForFrames(this.global).forEach(id =>
+ Services.console.resetWindow(id)
+ );
+ }
+ }
+
+ // End of request handlers.
+
+ // 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(message) {
+ if (message instanceof Ci.nsIScriptError) {
+ this.emit("pageError", {
+ pageError: this.preparePageErrorForRemote(message),
+ });
+ } else {
+ this.emit("logMessage", {
+ message: this._createStringGrip(message.message),
+ timeStamp: message.microSecondTimeStamp / 1000,
+ });
+ }
+ }
+
+ 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(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.microSecondTimeStamp / 1000,
+ 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.
+ * @param object extraProperties
+ * an object whose properties will be folded in the packet that is emitted.
+ */
+ onConsoleAPICall(message, extraProperties = {}) {
+ this.emit("consoleAPICall", {
+ message: this.prepareConsoleMessageForRemote(message),
+ ...extraProperties,
+ });
+ }
+
+ /**
+ * 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.
+ * @param {Boolean} hasNativeConsoleAPI
+ * Tells if the window.console object is native or overwritten by script in the page.
+ * Only passed when `name` is "dom-complete" (see devtools/server/actors/webconsole/listeners/document-events.js).
+ */
+ onDocumentEvent(name, { time, hasNativeConsoleAPI }) {
+ this.emit("documentEvent", {
+ name,
+ time,
+ hasNativeConsoleAPI,
+ });
+ }
+
+ /**
+ * 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(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 the console storage listener.
+ * @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(message, useObjectGlobal = true) {
+ const result = {
+ arguments: message.arguments
+ ? message.arguments.map(obj => {
+ const dbgObj = this.makeDebuggeeValue(obj, useObjectGlobal);
+ return this.createValueGrip(dbgObj);
+ })
+ : [],
+ chromeContext: message.chromeContext,
+ columnNumber: message.columnNumber,
+ filename: message.filename,
+ level: message.level,
+ lineNumber: message.lineNumber,
+ // messages emitted from Console.sys.mjs don't have a microSecondTimeStamp property
+ timeStamp: message.microSecondTimeStamp
+ ? message.microSecondTimeStamp / 1000
+ : message.timeStamp,
+ sourceId: this.getActorIdForInternalSourceId(message.sourceId),
+ category: message.category || "webdev",
+ innerWindowID: message.innerID,
+ };
+
+ // It only make sense to include the following properties in the message when they have
+ // a meaningful value. Otherwise we simply don't include them so we save cycles in JSActor communication.
+ if (message.counter) {
+ result.counter = message.counter;
+ }
+ if (message.private) {
+ result.private = message.private;
+ }
+ if (message.prefix) {
+ result.prefix = message.prefix;
+ }
+
+ if (message.stacktrace) {
+ result.stacktrace = message.stacktrace.map(frame => {
+ return {
+ ...frame,
+ sourceId: this.getActorIdForInternalSourceId(frame.sourceId),
+ };
+ });
+ }
+
+ if (message.styles && message.styles.length) {
+ result.styles = message.styles.map(string => {
+ return this.createValueGrip(string);
+ });
+ }
+
+ if (message.timer) {
+ result.timer = message.timer;
+ }
+
+ if (message.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);
+ }
+
+ 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(result) {
+ if (
+ !result ||
+ !Array.isArray(result.arguments) ||
+ !result.arguments.length
+ ) {
+ 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;
+ }
+
+ /**
+ * The "will-navigate" progress listener. This is used to clear the current
+ * eval scope.
+ */
+ _onWillNavigate({ 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() {
+ // 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;
diff --git a/devtools/server/actors/webconsole/commands/manager.js b/devtools/server/actors/webconsole/commands/manager.js
new file mode 100644
index 0000000000..3fa274a0f5
--- /dev/null
+++ b/devtools/server/actors/webconsole/commands/manager.js
@@ -0,0 +1,577 @@
+/* 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";
+
+loader.lazyRequireGetter(
+ this,
+ ["isCommand"],
+ "resource://devtools/server/actors/webconsole/commands/parser.js",
+ true
+);
+
+/**
+ * WebConsole commands manager.
+ *
+ * Defines a set of functions / variables ("commands") that are available from
+ * the Web Console but not from the web page.
+ *
+ */
+const WebConsoleCommandsManager = {
+ _registeredCommands: new Map(),
+
+ /**
+ * Register a new command.
+ * @param {string} name The command name (exemple: "$")
+ * @param {(function|object)} command The command to register.
+ * It can be a function so the command is a function (like "$()"),
+ * or it can also be a property descriptor to describe a getter(like
+ * "$0").
+ *
+ * The command function or the command getter are passed a owner object as
+ * their first parameter (see the example below).
+ *
+ * @example
+ *
+ * WebConsoleCommandsManager.register("$", function JSTH_$(owner, selector)
+ * {
+ * return owner.window.document.querySelector(selector);
+ * });
+ *
+ * WebConsoleCommandsManager.register("$0", {
+ * get: function(owner) {
+ * return owner.makeDebuggeeValue(owner.selectedNode);
+ * }
+ * });
+ */
+ register(name, command) {
+ if (
+ typeof command != "function" &&
+ !(typeof command == "object" && typeof command.get == "function")
+ ) {
+ throw new Error(
+ "Invalid web console command. It can only be a function, or an object with a function as 'get' attribute"
+ );
+ }
+ this._registeredCommands.set(name, command);
+ },
+
+ /**
+ * Return the name of all registered commands.
+ *
+ * @return {array} List of all command names.
+ */
+ getAllCommandNames() {
+ return [...this._registeredCommands.keys()];
+ },
+
+ /**
+ * There is two types of "commands" here.
+ *
+ * - Functions or variables exposed in the scope of the evaluated string from the WebConsole input.
+ * Example: $(), $0, copy(), clear(),...
+ * - "True commands", which can also be ran from the WebConsole input with ":" prefix.
+ * Example: this list of commands.
+ * Note that some "true commands" are not exposed as function (see getColonOnlyCommandNames).
+ *
+ * The following list distinguish these "true commands" from the first category.
+ * It especially avoid any JavaScript evaluation when the frontend tries to execute
+ * a string starting with ':' character.
+ */
+ getAllColonCommandNames() {
+ return ["block", "help", "history", "screenshot", "unblock"];
+ },
+
+ /**
+ * Some commands are not exposed in the scope of the evaluated string,
+ * and can only be used via `:command-name`.
+ */
+ getColonOnlyCommandNames() {
+ return ["screenshot"];
+ },
+
+ /**
+ * Map of all command objects keyed by command name.
+ * Commands object are the objects passed to register() method.
+ *
+ * @return {Map<string -> command>}
+ */
+ getAllCommands() {
+ return this._registeredCommands;
+ },
+
+ /**
+ * Is the command name possibly overriding a symbol which
+ * already exists in the paused frame, or global into which
+ * we are about to execute into?
+ */
+ _isCommandNameAlreadyInScope(name, frame, dbgGlobal) {
+ // Fallback on global scope when Debugger.Frame doesn't come along an Environment.
+ if (frame && frame.environment) {
+ return !!frame.environment.find(name);
+ }
+ return !!dbgGlobal.getOwnPropertyDescriptor(name);
+ },
+
+ /**
+ * 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.
+ *
+ * @param object consoleActor
+ * The related web console actor evaluating some code.
+ * @param object debuggerGlobal
+ * A Debugger.Object that wraps a content global. This is used for the
+ * Web Console Commands.
+ * @param object frame (optional)
+ * The frame where the string was evaluated.
+ * @param string evalInput
+ * String to evaluate.
+ * @param string selectedNodeActorID
+ * The Node actor ID of the currently selected DOM Element, if any is selected.
+ *
+ * @return object
+ * Object with two properties:
+ * - 'bindings', the object with all commands set as attribute on this object.
+ * - 'getHelperResult', a live getter returning the additional data the last command
+ * which executed want to convey to the frontend.
+ * (The return value of commands isn't returned to the client but it only
+ * returned to the code ran from console evaluation)
+ */
+ getWebConsoleCommands(
+ consoleActor,
+ debuggerGlobal,
+ frame,
+ evalInput,
+ selectedNodeActorID
+ ) {
+ const bindings = Object.create(null);
+
+ const owner = {
+ window: consoleActor.evalGlobal,
+ makeDebuggeeValue: debuggerGlobal.makeDebuggeeValue.bind(debuggerGlobal),
+ createValueGrip: consoleActor.createValueGrip.bind(consoleActor),
+ preprocessDebuggerObject:
+ consoleActor.preprocessDebuggerObject.bind(consoleActor),
+ helperResult: null,
+ consoleActor,
+ evalInput,
+ };
+ if (selectedNodeActorID) {
+ const actor = consoleActor.conn.getActor(selectedNodeActorID);
+ if (actor) {
+ owner.selectedNode = actor.rawNode;
+ }
+ }
+
+ const evalGlobal = consoleActor.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,
+ });
+ }
+
+ // Not supporting extra commands in workers yet. This should be possible to
+ // add one by one as long as they don't require jsm, Cu, etc.
+ const commands = isWorker ? [] : this.getAllCommands();
+
+ // Is it a command evaluation, starting with ':' prefix?
+ const isCmd = isCommand(evalInput);
+
+ const colonOnlyCommandNames = this.getColonOnlyCommandNames();
+ for (const [name, command] of commands) {
+ // When we are running command via `:` prefix, no user code is being ran and only the command executes,
+ // so always expose the commands as the command will try to call its JavaScript method (see getEvalInput).
+ // Otherwise, when we run user code, we want to avoid overriding existing symbols with commands.
+ // Also ignore commands which can only be run with the `:` prefix.
+ if (
+ !isCmd &&
+ (this._isCommandNameAlreadyInScope(name, frame, debuggerGlobal) ||
+ colonOnlyCommandNames.includes(name))
+ ) {
+ continue;
+ }
+
+ const descriptor = {
+ // We force the enumerability and the configurability (so the
+ // WebConsoleActor can reconfigure the property).
+ enumerable: true,
+ configurable: true,
+ };
+
+ if (typeof command === "function") {
+ // Function commands
+ descriptor.value = command.bind(undefined, owner);
+ maybeExport(descriptor, "value");
+ // Make sure the helpers can be used during eval.
+ descriptor.value = debuggerGlobal.makeDebuggeeValue(descriptor.value);
+ } else if (typeof command?.get === "function") {
+ // Getter commands
+ descriptor.get = command.get.bind(undefined, owner);
+ maybeExport(descriptor, "get");
+ }
+ Object.defineProperty(bindings, name, descriptor);
+ }
+
+ return {
+ // Use a method as commands will update owner.helperResult later
+ getHelperResult() {
+ return owner.helperResult;
+ },
+ bindings,
+ };
+ },
+};
+
+exports.WebConsoleCommandsManager = WebConsoleCommandsManager;
+
+/*
+ * Built-in commands.
+ *
+ * A list of helper functions used by Firebug can be found here:
+ * http://getfirebug.com/wiki/index.php/Command_Line_API
+ */
+
+/**
+ * Find a node by ID.
+ *
+ * @param string id
+ * The ID of the element you want.
+ * @return Node or null
+ * The result of calling document.querySelector(selector).
+ */
+WebConsoleCommandsManager.register("$", function (owner, selector) {
+ try {
+ return owner.window.document.querySelector(selector);
+ } catch (err) {
+ // Throw an error like `err` but that belongs to `owner.window`.
+ throw new owner.window.DOMException(err.message, err.name);
+ }
+});
+
+/**
+ * Find the nodes matching a CSS selector.
+ *
+ * @param string selector
+ * A string that is passed to window.document.querySelectorAll.
+ * @return NodeList
+ * Returns the result of document.querySelectorAll(selector).
+ */
+WebConsoleCommandsManager.register("$$", function (owner, selector) {
+ let nodes;
+ try {
+ nodes = owner.window.document.querySelectorAll(selector);
+ } catch (err) {
+ // Throw an error like `err` but that belongs to `owner.window`.
+ throw new owner.window.DOMException(err.message, err.name);
+ }
+
+ // Calling owner.window.Array.from() doesn't work without accessing the
+ // wrappedJSObject, so just loop through the results instead.
+ const result = new owner.window.Array();
+ for (let i = 0; i < nodes.length; i++) {
+ result.push(nodes[i]);
+ }
+ return result;
+});
+
+/**
+ * Returns the result of the last console input evaluation
+ *
+ * @return object|undefined
+ * Returns last console evaluation or undefined
+ */
+WebConsoleCommandsManager.register("$_", {
+ get(owner) {
+ return owner.consoleActor.getLastConsoleInputEvaluation();
+ },
+});
+
+/**
+ * Runs an xPath query and returns all matched nodes.
+ *
+ * @param string xPath
+ * xPath search query to execute.
+ * @param [optional] Node context
+ * Context to run the xPath query on. Uses window.document if not set.
+ * @param [optional] string|number resultType
+ Specify the result type. Default value XPathResult.ANY_TYPE
+ * @return array of Node
+ */
+WebConsoleCommandsManager.register(
+ "$x",
+ function (
+ owner,
+ xPath,
+ context,
+ resultType = owner.window.XPathResult.ANY_TYPE
+ ) {
+ const nodes = new owner.window.Array();
+ // Not waiving Xrays, since we want the original Document.evaluate function,
+ // instead of anything that's been redefined.
+ const doc = owner.window.document;
+ context = context || doc;
+ switch (resultType) {
+ case "number":
+ resultType = owner.window.XPathResult.NUMBER_TYPE;
+ break;
+
+ case "string":
+ resultType = owner.window.XPathResult.STRING_TYPE;
+ break;
+
+ case "bool":
+ resultType = owner.window.XPathResult.BOOLEAN_TYPE;
+ break;
+
+ case "node":
+ resultType = owner.window.XPathResult.FIRST_ORDERED_NODE_TYPE;
+ break;
+
+ case "nodes":
+ resultType = owner.window.XPathResult.UNORDERED_NODE_ITERATOR_TYPE;
+ break;
+ }
+ const results = doc.evaluate(xPath, context, null, resultType, null);
+ if (results.resultType === owner.window.XPathResult.NUMBER_TYPE) {
+ return results.numberValue;
+ }
+ if (results.resultType === owner.window.XPathResult.STRING_TYPE) {
+ return results.stringValue;
+ }
+ if (results.resultType === owner.window.XPathResult.BOOLEAN_TYPE) {
+ return results.booleanValue;
+ }
+ if (
+ results.resultType === owner.window.XPathResult.ANY_UNORDERED_NODE_TYPE ||
+ results.resultType === owner.window.XPathResult.FIRST_ORDERED_NODE_TYPE
+ ) {
+ return results.singleNodeValue;
+ }
+ if (
+ results.resultType ===
+ owner.window.XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE ||
+ results.resultType === owner.window.XPathResult.ORDERED_NODE_SNAPSHOT_TYPE
+ ) {
+ for (let i = 0; i < results.snapshotLength; i++) {
+ nodes.push(results.snapshotItem(i));
+ }
+ return nodes;
+ }
+
+ let node;
+ while ((node = results.iterateNext())) {
+ nodes.push(node);
+ }
+
+ return nodes;
+ }
+);
+
+/**
+ * Returns the currently selected object in the highlighter.
+ *
+ * @return Object representing the current selection in the
+ * Inspector, or null if no selection exists.
+ */
+WebConsoleCommandsManager.register("$0", {
+ get(owner) {
+ return owner.makeDebuggeeValue(owner.selectedNode);
+ },
+});
+
+/**
+ * Clears the output of the WebConsole.
+ */
+WebConsoleCommandsManager.register("clear", function (owner) {
+ owner.helperResult = {
+ type: "clearOutput",
+ };
+});
+
+/**
+ * Clears the input history of the WebConsole.
+ */
+WebConsoleCommandsManager.register("clearHistory", function (owner) {
+ owner.helperResult = {
+ type: "clearHistory",
+ };
+});
+
+/**
+ * Returns the result of Object.keys(object).
+ *
+ * @param object object
+ * Object to return the property names from.
+ * @return array of strings
+ */
+WebConsoleCommandsManager.register("keys", function (owner, object) {
+ // Need to waive Xrays so we can iterate functions and accessor properties
+ return Cu.cloneInto(Object.keys(Cu.waiveXrays(object)), owner.window);
+});
+
+/**
+ * Returns the values of all properties on object.
+ *
+ * @param object object
+ * Object to display the values from.
+ * @return array of string
+ */
+WebConsoleCommandsManager.register("values", function (owner, object) {
+ const values = [];
+ // Need to waive Xrays so we can iterate functions and accessor properties
+ const waived = Cu.waiveXrays(object);
+ const names = Object.getOwnPropertyNames(waived);
+
+ for (const name of names) {
+ values.push(waived[name]);
+ }
+
+ return Cu.cloneInto(values, owner.window);
+});
+
+/**
+ * Opens a help window in MDN.
+ */
+WebConsoleCommandsManager.register("help", function (owner) {
+ owner.helperResult = { type: "help" };
+});
+
+/**
+ * Inspects the passed object. This is done by opening the PropertyPanel.
+ *
+ * @param object object
+ * Object to inspect.
+ */
+WebConsoleCommandsManager.register(
+ "inspect",
+ function (owner, object, forceExpandInConsole = false) {
+ const dbgObj = owner.preprocessDebuggerObject(
+ owner.makeDebuggeeValue(object)
+ );
+
+ const grip = owner.createValueGrip(dbgObj);
+ owner.helperResult = {
+ type: "inspectObject",
+ input: owner.evalInput,
+ object: grip,
+ forceExpandInConsole,
+ };
+ }
+);
+
+/**
+ * Copy the String representation of a value to the clipboard.
+ *
+ * @param any value
+ * A value you want to copy as a string.
+ * @return void
+ */
+WebConsoleCommandsManager.register("copy", function (owner, value) {
+ let payload;
+ try {
+ if (Element.isInstance(value)) {
+ payload = value.outerHTML;
+ } else if (typeof value == "string") {
+ payload = value;
+ } else {
+ payload = JSON.stringify(value, null, " ");
+ }
+ } catch (ex) {
+ owner.helperResult = {
+ type: "error",
+ message: "webconsole.error.commands.copyError",
+ messageArgs: [ex.toString()],
+ };
+ return;
+ }
+ owner.helperResult = {
+ type: "copyValueToClipboard",
+ value: payload,
+ };
+});
+
+/**
+ * Take a screenshot of a page.
+ *
+ * @param object args
+ * The arguments to be passed to the screenshot
+ * @return void
+ */
+WebConsoleCommandsManager.register("screenshot", function (owner, args = {}) {
+ owner.helperResult = {
+ type: "screenshotOutput",
+ args,
+ };
+});
+
+/**
+ * Shows a history of commands and expressions previously executed within the command line.
+ *
+ * @param object args
+ * The arguments to be passed to the history
+ * @return void
+ */
+WebConsoleCommandsManager.register("history", function (owner, args = {}) {
+ owner.helperResult = {
+ type: "historyOutput",
+ args,
+ };
+});
+
+/**
+ * Block specific resource from loading
+ *
+ * @param object args
+ * an object with key "url", i.e. a filter
+ *
+ * @return void
+ */
+WebConsoleCommandsManager.register("block", function (owner, args = {}) {
+ if (!args.url) {
+ owner.helperResult = {
+ type: "error",
+ message: "webconsole.messages.commands.blockArgMissing",
+ };
+ return;
+ }
+
+ owner.helperResult = {
+ type: "blockURL",
+ args,
+ };
+});
+
+/*
+ * Unblock a blocked a resource
+ *
+ * @param object filter
+ * an object with key "url", i.e. a filter
+ *
+ * @return void
+ */
+WebConsoleCommandsManager.register("unblock", function (owner, args = {}) {
+ if (!args.url) {
+ owner.helperResult = {
+ type: "error",
+ message: "webconsole.messages.commands.blockArgMissing",
+ };
+ return;
+ }
+
+ owner.helperResult = {
+ type: "unblockURL",
+ args,
+ };
+});
diff --git a/devtools/server/actors/webconsole/commands/moz.build b/devtools/server/actors/webconsole/commands/moz.build
new file mode 100644
index 0000000000..9e0516b172
--- /dev/null
+++ b/devtools/server/actors/webconsole/commands/moz.build
@@ -0,0 +1,10 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+DevToolsModules(
+ "manager.js",
+ "parser.js",
+)
diff --git a/devtools/server/actors/webconsole/commands/parser.js b/devtools/server/actors/webconsole/commands/parser.js
new file mode 100644
index 0000000000..b37d489c5f
--- /dev/null
+++ b/devtools/server/actors/webconsole/commands/parser.js
@@ -0,0 +1,249 @@
+/* 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";
+
+loader.lazyRequireGetter(
+ this,
+ ["WebConsoleCommandsManager"],
+ "resource://devtools/server/actors/webconsole/commands/manager.js",
+ true
+);
+
+const COMMAND = "command";
+const KEY = "key";
+const ARG = "arg";
+
+const COMMAND_PREFIX = /^:/;
+const KEY_PREFIX = /^--/;
+
+// default value for flags
+const DEFAULT_VALUE = true;
+const COMMAND_DEFAULT_FLAG = {
+ block: "url",
+ screenshot: "filename",
+ unblock: "url",
+};
+
+/**
+ * When given a string that begins with `:` and a unix style string,
+ * format a JS like object.
+ * This is intended to be used by the WebConsole actor only.
+ *
+ * @param String string
+ * A string to format that begins with `:`.
+ *
+ * @returns String formatted as `command({ ..args })`
+ */
+function formatCommand(string) {
+ if (!isCommand(string)) {
+ throw Error("formatCommand was called without `:`");
+ }
+ const tokens = string.trim().split(/\s+/).map(createToken);
+ const { command, args } = parseCommand(tokens);
+ const argsString = formatArgs(args);
+ return `${command}(${argsString})`;
+}
+
+/**
+ * collapses the array of arguments from the parsed command into
+ * a single string
+ *
+ * @param Object tree
+ * A tree object produced by parseCommand
+ *
+ * @returns String formatted as ` { key: value, ... } ` or an empty string
+ */
+function formatArgs(args) {
+ return Object.keys(args).length ? JSON.stringify(args) : "";
+}
+
+/**
+ * creates a token object depending on a string which as a prefix,
+ * either `:` for a command or `--` for a key, or nothing for an argument
+ *
+ * @param String string
+ * A string to use as the basis for the token
+ *
+ * @returns Object Token Object, with the following shape
+ * { type: String, value: String }
+ */
+function createToken(string) {
+ if (isCommand(string)) {
+ const value = string.replace(COMMAND_PREFIX, "");
+ if (
+ !value ||
+ !WebConsoleCommandsManager.getAllColonCommandNames().includes(value)
+ ) {
+ throw Error(`'${value}' is not a valid command`);
+ }
+ return { type: COMMAND, value };
+ }
+ if (isKey(string)) {
+ const value = string.replace(KEY_PREFIX, "");
+ if (!value) {
+ throw Error("invalid flag");
+ }
+ return { type: KEY, value };
+ }
+ return { type: ARG, value: string };
+}
+
+/**
+ * returns a command Tree object for a set of tokens
+ *
+ *
+ * @param Array Tokens tokens
+ * An array of Token objects
+ *
+ * @returns Object Tree Object, with the following shape
+ * { command: String, args: Array of Strings }
+ */
+function parseCommand(tokens) {
+ let command = null;
+ const args = {};
+
+ for (let i = 0; i < tokens.length; i++) {
+ const token = tokens[i];
+ if (token.type === COMMAND) {
+ if (command) {
+ // we are throwing here because two commands have been passed and it is unclear
+ // what the user's intention was
+ throw Error("Invalid command");
+ }
+ command = token.value;
+ }
+
+ if (token.type === KEY) {
+ const nextTokenIndex = i + 1;
+ const nextToken = tokens[nextTokenIndex];
+ let values = args[token.value] || DEFAULT_VALUE;
+ if (nextToken && nextToken.type === ARG) {
+ const { value, offset } = collectString(
+ nextToken,
+ tokens,
+ nextTokenIndex
+ );
+ // in order for JSON.stringify to correctly output values, they must be correctly
+ // typed
+ // As per the old GCLI documentation, we can only have one value associated with a
+ // flag but multiple flags with the same name can exist and should be combined
+ // into and array. Here we are associating only the value on the right hand
+ // side if it is of type `arg` as a single value; the second case initializes
+ // an array, and the final case pushes a value to an existing array
+ const typedValue = getTypedValue(value);
+ if (values === DEFAULT_VALUE) {
+ values = typedValue;
+ } else if (!Array.isArray(values)) {
+ values = [values, typedValue];
+ } else {
+ values.push(typedValue);
+ }
+ // skip the next token since we have already consumed it
+ i = nextTokenIndex + offset;
+ }
+ args[token.value] = values;
+ }
+
+ // Since this has only been implemented for screenshot, we can only have one default
+ // value. Eventually we may have more default values. For now, ignore multiple
+ // unflagged args
+ const defaultFlag = COMMAND_DEFAULT_FLAG[command];
+ if (token.type === ARG && !args[defaultFlag]) {
+ const { value, offset } = collectString(token, tokens, i);
+ args[defaultFlag] = getTypedValue(value);
+ i = i + offset;
+ }
+ }
+ return { command, args };
+}
+
+const stringChars = ['"', "'", "`"];
+function isStringChar(testChar) {
+ return stringChars.includes(testChar);
+}
+
+function checkLastChar(string, testChar) {
+ const lastChar = string[string.length - 1];
+ return lastChar === testChar;
+}
+
+function hasUnescapedChar(value, char, rightOffset, leftOffset) {
+ const lastPos = value.length - 1;
+ const string = value.slice(rightOffset, lastPos - leftOffset);
+ const index = string.indexOf(char);
+ if (index === -1) {
+ return false;
+ }
+ const prevChar = index > 0 ? string[index - 1] : null;
+ // return false if the unexpected character is escaped, true if it is not
+ return prevChar !== "\\";
+}
+
+function collectString(token, tokens, index) {
+ const firstChar = token.value[0];
+ const isString = isStringChar(firstChar);
+ const UNESCAPED_CHAR_ERROR = segment =>
+ `String has unescaped \`${firstChar}\` in [${segment}...],` +
+ " may miss a space between arguments";
+ let value = token.value;
+
+ // the test value is not a string, or it is a string but a complete one
+ // i.e. `"test"`, as opposed to `"foo`. In either case, this we can return early
+ if (!isString || checkLastChar(value, firstChar)) {
+ return { value, offset: 0 };
+ }
+
+ if (hasUnescapedChar(value, firstChar, 1, 0)) {
+ throw Error(UNESCAPED_CHAR_ERROR(value));
+ }
+
+ let offset = null;
+ for (let i = index + 1; i <= tokens.length; i++) {
+ if (i === tokens.length) {
+ throw Error("String does not terminate");
+ }
+
+ const nextToken = tokens[i];
+ if (nextToken.type !== ARG) {
+ throw Error(`String does not terminate before flag "${nextToken.value}"`);
+ }
+
+ value = `${value} ${nextToken.value}`;
+
+ if (hasUnescapedChar(nextToken.value, firstChar, 0, 1)) {
+ throw Error(UNESCAPED_CHAR_ERROR(value));
+ }
+
+ if (checkLastChar(nextToken.value, firstChar)) {
+ offset = i - index;
+ break;
+ }
+ }
+ return { value, offset };
+}
+
+function isCommand(string) {
+ return COMMAND_PREFIX.test(string);
+}
+
+function isKey(string) {
+ return KEY_PREFIX.test(string);
+}
+
+function getTypedValue(value) {
+ if (!isNaN(value)) {
+ return Number(value);
+ }
+ if (value === "true" || value === "false") {
+ return Boolean(value);
+ }
+ if (isStringChar(value[0])) {
+ return value.slice(1, value.length - 1);
+ }
+ return value;
+}
+
+exports.formatCommand = formatCommand;
+exports.isCommand = isCommand;
diff --git a/devtools/server/actors/webconsole/eager-ecma-allowlist.js b/devtools/server/actors/webconsole/eager-ecma-allowlist.js
new file mode 100644
index 0000000000..eb0bf7ac0c
--- /dev/null
+++ b/devtools/server/actors/webconsole/eager-ecma-allowlist.js
@@ -0,0 +1,248 @@
+/* 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/. */
+/* global BigInt */
+
+"use strict";
+
+function matchingProperties(obj, regexp) {
+ return Object.getOwnPropertyNames(obj)
+ .filter(n => regexp.test(n))
+ .map(n => obj[n])
+ .filter(v => typeof v == "function");
+}
+
+function allProperties(obj) {
+ return matchingProperties(obj, /./);
+}
+
+function getter(obj, name) {
+ return Object.getOwnPropertyDescriptor(obj, name).get;
+}
+
+const TypedArray = Reflect.getPrototypeOf(Int8Array);
+
+const functionAllowList = [
+ Array,
+ Array.from,
+ Array.isArray,
+ Array.of,
+ Array.prototype.concat,
+ Array.prototype.entries,
+ Array.prototype.every,
+ Array.prototype.filter,
+ Array.prototype.find,
+ Array.prototype.findIndex,
+ Array.prototype.flat,
+ Array.prototype.flatMap,
+ Array.prototype.forEach,
+ Array.prototype.includes,
+ Array.prototype.indexOf,
+ Array.prototype.join,
+ Array.prototype.keys,
+ Array.prototype.lastIndexOf,
+ Array.prototype.map,
+ Array.prototype.reduce,
+ Array.prototype.reduceRight,
+ Array.prototype.slice,
+ Array.prototype.some,
+ Array.prototype.values,
+ ArrayBuffer,
+ ArrayBuffer.isView,
+ ArrayBuffer.prototype.slice,
+ BigInt,
+ ...allProperties(BigInt),
+ Boolean,
+ DataView,
+ Date,
+ Date.now,
+ Date.parse,
+ Date.UTC,
+ ...matchingProperties(Date.prototype, /^get/),
+ ...matchingProperties(Date.prototype, /^to.*?String$/),
+ Error,
+ Function,
+ Function.prototype.apply,
+ Function.prototype.bind,
+ Function.prototype.call,
+ Function.prototype[Symbol.hasInstance],
+ Int8Array,
+ Uint8Array,
+ Uint8ClampedArray,
+ Int16Array,
+ Uint16Array,
+ Int32Array,
+ Uint32Array,
+ Float32Array,
+ Float64Array,
+ TypedArray.from,
+ TypedArray.of,
+ TypedArray.prototype.entries,
+ TypedArray.prototype.every,
+ TypedArray.prototype.filter,
+ TypedArray.prototype.find,
+ TypedArray.prototype.findIndex,
+ TypedArray.prototype.forEach,
+ TypedArray.prototype.includes,
+ TypedArray.prototype.indexOf,
+ TypedArray.prototype.join,
+ TypedArray.prototype.keys,
+ TypedArray.prototype.lastIndexOf,
+ TypedArray.prototype.map,
+ TypedArray.prototype.reduce,
+ TypedArray.prototype.reduceRight,
+ TypedArray.prototype.slice,
+ TypedArray.prototype.some,
+ TypedArray.prototype.subarray,
+ TypedArray.prototype.values,
+ ...allProperties(JSON),
+ Map,
+ Map.prototype.forEach,
+ Map.prototype.get,
+ Map.prototype.has,
+ Map.prototype.entries,
+ Map.prototype.keys,
+ Map.prototype.values,
+ ...allProperties(Math),
+ Number,
+ ...allProperties(Number),
+ ...allProperties(Number.prototype),
+ Object,
+ Object.create,
+ Object.keys,
+ Object.entries,
+ Object.getOwnPropertyDescriptor,
+ Object.getOwnPropertyDescriptors,
+ Object.getOwnPropertyNames,
+ Object.getOwnPropertySymbols,
+ Object.getPrototypeOf,
+ Object.is,
+ Object.isExtensible,
+ Object.isFrozen,
+ Object.isSealed,
+ Object.values,
+ Object.prototype.hasOwnProperty,
+ Object.prototype.isPrototypeOf,
+ Proxy,
+ Proxy.revocable,
+ Reflect.apply,
+ Reflect.construct,
+ Reflect.get,
+ Reflect.getOwnPropertyDescriptor,
+ Reflect.getPrototypeOf,
+ Reflect.has,
+ Reflect.isExtensible,
+ Reflect.ownKeys,
+ RegExp,
+ RegExp.prototype.exec,
+ RegExp.prototype.test,
+ RegExp.prototype[Symbol.match],
+ RegExp.prototype[Symbol.search],
+ RegExp.prototype[Symbol.replace],
+ Set,
+ Set.prototype.entries,
+ Set.prototype.forEach,
+ Set.prototype.has,
+ Set.prototype.values,
+ String,
+ ...allProperties(String),
+ ...allProperties(String.prototype),
+ Symbol,
+ Symbol.keyFor,
+ WeakMap,
+ WeakMap.prototype.get,
+ WeakMap.prototype.has,
+ WeakSet,
+ WeakSet.prototype.has,
+ decodeURI,
+ decodeURIComponent,
+ encodeURI,
+ encodeURIComponent,
+ escape,
+ isFinite,
+ isNaN,
+ unescape,
+];
+
+const getterAllowList = [
+ getter(ArrayBuffer.prototype, "byteLength"),
+ getter(ArrayBuffer, Symbol.species),
+ getter(Array, Symbol.species),
+ getter(DataView.prototype, "buffer"),
+ getter(DataView.prototype, "byteLength"),
+ getter(DataView.prototype, "byteOffset"),
+ getter(Error.prototype, "stack"),
+ getter(Function.prototype, "arguments"),
+ getter(Function.prototype, "caller"),
+ getter(Intl.Locale.prototype, "baseName"),
+ getter(Intl.Locale.prototype, "calendar"),
+ getter(Intl.Locale.prototype, "caseFirst"),
+ getter(Intl.Locale.prototype, "collation"),
+ getter(Intl.Locale.prototype, "hourCycle"),
+ getter(Intl.Locale.prototype, "numeric"),
+ getter(Intl.Locale.prototype, "numberingSystem"),
+ getter(Intl.Locale.prototype, "language"),
+ getter(Intl.Locale.prototype, "script"),
+ getter(Intl.Locale.prototype, "region"),
+ getter(Map.prototype, "size"),
+ getter(Map, Symbol.species),
+ // NOTE: Object.prototype.__proto__ is not safe, because it can internally
+ // invoke Proxy getPrototypeOf handler.
+ getter(Promise, Symbol.species),
+ getter(RegExp, "input"),
+ getter(RegExp, "lastMatch"),
+ getter(RegExp, "lastParen"),
+ getter(RegExp, "leftContext"),
+ getter(RegExp, "rightContext"),
+ getter(RegExp, "$1"),
+ getter(RegExp, "$2"),
+ getter(RegExp, "$3"),
+ getter(RegExp, "$4"),
+ getter(RegExp, "$5"),
+ getter(RegExp, "$6"),
+ getter(RegExp, "$7"),
+ getter(RegExp, "$8"),
+ getter(RegExp, "$9"),
+ getter(RegExp, "$_"),
+ getter(RegExp, "$&"),
+ getter(RegExp, "$+"),
+ getter(RegExp, "$`"),
+ getter(RegExp, "$'"),
+ getter(RegExp.prototype, "dotAll"),
+ getter(RegExp.prototype, "flags"),
+ getter(RegExp.prototype, "global"),
+ getter(RegExp.prototype, "hasIndices"),
+ getter(RegExp.prototype, "ignoreCase"),
+ getter(RegExp.prototype, "multiline"),
+ getter(RegExp.prototype, "source"),
+ getter(RegExp.prototype, "sticky"),
+ getter(RegExp.prototype, "unicode"),
+ getter(RegExp, Symbol.species),
+ getter(Set.prototype, "size"),
+ getter(Set, Symbol.species),
+ getter(Symbol.prototype, "description"),
+ getter(TypedArray.prototype, "buffer"),
+ getter(TypedArray.prototype, "byteLength"),
+ getter(TypedArray.prototype, "byteOffset"),
+ getter(TypedArray.prototype, "length"),
+ getter(TypedArray.prototype, Symbol.toStringTag),
+ getter(TypedArray, Symbol.species),
+];
+
+// TODO: Integrate in main list when changes array by copy ships by default
+const changesArrayByCopy = [
+ Array.prototype.toReversed,
+ Array.prototype.toSorted,
+ Array.prototype.toSpliced,
+ Array.prototype.with,
+ TypedArray.prototype.toReversed,
+ TypedArray.prototype.toSorted,
+ TypedArray.prototype.with,
+];
+for (const fn of changesArrayByCopy) {
+ if (typeof fn == "function") {
+ functionAllowList.push(fn);
+ }
+}
+
+module.exports = { functions: functionAllowList, getters: getterAllowList };
diff --git a/devtools/server/actors/webconsole/eager-function-allowlist.js b/devtools/server/actors/webconsole/eager-function-allowlist.js
new file mode 100644
index 0000000000..363591523d
--- /dev/null
+++ b/devtools/server/actors/webconsole/eager-function-allowlist.js
@@ -0,0 +1,52 @@
+/* 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";
+
+const idlPureAllowlist = require("resource://devtools/server/actors/webconsole/webidl-pure-allowlist.js");
+
+const natives = [];
+if (Components.Constructor && Cu) {
+ const sandbox = Cu.Sandbox(
+ Components.Constructor("@mozilla.org/systemprincipal;1", "nsIPrincipal")(),
+ {
+ invisibleToDebugger: true,
+ wantGlobalProperties: Object.keys(idlPureAllowlist),
+ }
+ );
+
+ function maybePush(maybeFunc) {
+ if (maybeFunc) {
+ natives.push(maybeFunc);
+ }
+ }
+
+ function collectMethods(obj, methods) {
+ for (const name of methods) {
+ maybePush(obj[name]);
+ }
+ }
+
+ for (const [iface, ifaceData] of Object.entries(idlPureAllowlist)) {
+ const ctor = sandbox[iface];
+ if (!ctor) {
+ continue;
+ }
+
+ if ("static" in ifaceData) {
+ collectMethods(ctor, ifaceData.static);
+ }
+
+ if ("prototype" in ifaceData) {
+ const proto = ctor.prototype;
+ if (!proto) {
+ continue;
+ }
+
+ collectMethods(proto, ifaceData.prototype);
+ }
+ }
+}
+
+module.exports = { natives };
diff --git a/devtools/server/actors/webconsole/eval-with-debugger.js b/devtools/server/actors/webconsole/eval-with-debugger.js
new file mode 100644
index 0000000000..3d2c4fae49
--- /dev/null
+++ b/devtools/server/actors/webconsole/eval-with-debugger.js
@@ -0,0 +1,618 @@
+/* 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";
+
+const Debugger = require("Debugger");
+const DevToolsUtils = require("resource://devtools/shared/DevToolsUtils.js");
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ Reflect: "resource://gre/modules/reflect.sys.mjs",
+});
+loader.lazyRequireGetter(
+ this,
+ ["formatCommand", "isCommand"],
+ "resource://devtools/server/actors/webconsole/commands/parser.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "WebConsoleCommandsManager",
+ "resource://devtools/server/actors/webconsole/commands/manager.js",
+ true
+);
+
+loader.lazyRequireGetter(
+ this,
+ "LongStringActor",
+ "resource://devtools/server/actors/string.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "eagerEcmaAllowlist",
+ "resource://devtools/server/actors/webconsole/eager-ecma-allowlist.js"
+);
+loader.lazyRequireGetter(
+ this,
+ "eagerFunctionAllowlist",
+ "resource://devtools/server/actors/webconsole/eager-function-allowlist.js"
+);
+
+function isObject(value) {
+ return Object(value) === value;
+}
+
+/**
+ * Evaluates a string using the debugger API.
+ *
+ * To allow the variables view to update properties from the Web Console we
+ * provide the "selectedObjectActor" mechanism: the Web Console tells the
+ * ObjectActor ID for which it desires to evaluate an expression. The
+ * Debugger.Object pointed at by the actor ID is bound such that it is
+ * available during expression evaluation (executeInGlobalWithBindings()).
+ *
+ * Example:
+ * _self['foobar'] = 'test'
+ * where |_self| refers to the desired object.
+ *
+ * The |frameActor| property allows the Web Console client to provide the
+ * frame actor ID, such that the expression can be evaluated in the
+ * user-selected stack frame.
+ *
+ * For the above to work we need the debugger and the Web Console to share
+ * a connection, otherwise the Web Console actor will not find the frame
+ * actor.
+ *
+ * The Debugger.Frame comes from the jsdebugger's Debugger instance, which
+ * is different from the Web Console's Debugger instance. This means that
+ * for evaluation to work, we need to create a new instance for the Web
+ * Console Commands helpers - they need to be Debugger.Objects coming from the
+ * jsdebugger's Debugger instance.
+ *
+ * When |selectedObjectActor| is used objects can come from different iframes,
+ * from different domains. To avoid permission-related errors when objects
+ * come from a different window, we also determine the object's own global,
+ * such that evaluation happens in the context of that global. This means that
+ * evaluation will happen in the object's iframe, rather than the top level
+ * window.
+ *
+ * @param string string
+ * String to evaluate.
+ * @param object [options]
+ * Options for evaluation:
+ * - selectedObjectActor: the ObjectActor ID to use for evaluation.
+ * |evalWithBindings()| will be called with one additional binding:
+ * |_self| which will point to the Debugger.Object of the given
+ * ObjectActor. Executes with the top level window as the global.
+ * - frameActor: the FrameActor ID to use for evaluation. The given
+ * debugger frame is used for evaluation, instead of the global window.
+ * - selectedNodeActor: the NodeActor ID of the currently selected node
+ * in the Inspector (or null, if there is no selection). This is used
+ * for helper functions that make reference to the currently selected
+ * node, like $0.
+ * - innerWindowID: An optional window id to use instead of webConsole.evalWindow.
+ * This is used by function that need to evaluate in a different window for which
+ * we don't have a dedicated target (for example a non-remote iframe).
+ * - eager: Set to true if you want the evaluation to bail if it may have side effects.
+ * - url: the url to evaluate the script as. Defaults to "debugger eval code",
+ * or "debugger eager eval code" if eager is true.
+ * @param object webConsole
+ *
+ * @return object
+ * An object that holds the following properties:
+ * - dbg: the debugger where the string was evaluated.
+ * - frame: (optional) the frame where the string was evaluated.
+ * - global: the Debugger.Object for the global where the string was evaluated in.
+ * - result: the result of the evaluation.
+ */
+exports.evalWithDebugger = function (string, options = {}, webConsole) {
+ if (isCommand(string.trim()) && options.eager) {
+ return {
+ result: null,
+ };
+ }
+
+ const evalString = getEvalInput(string);
+ const { frame, dbg } = getFrameDbg(options, webConsole);
+
+ const { dbgGlobal, bindSelf } = getDbgGlobal(options, dbg, webConsole);
+
+ const helpers = WebConsoleCommandsManager.getWebConsoleCommands(
+ webConsole,
+ dbgGlobal,
+ frame,
+ string,
+ options.selectedNodeActor
+ );
+ let { bindings } = helpers;
+
+ // '_self' refers to the JS object references via options.selectedObjectActor.
+ // This isn't exposed on typical console evaluation, but only when "Store As Global"
+ // runs an invisible script storing `_self` into `temp${i}`.
+ if (bindSelf) {
+ bindings._self = bindSelf;
+ }
+
+ // Log points calls this method from the server side and pass additional variables
+ // to be exposed to the evaluated JS string
+ if (options.bindings) {
+ bindings = { ...bindings, ...options.bindings };
+ }
+
+ const evalOptions = {};
+
+ const urlOption =
+ options.url || (options.eager ? "debugger eager eval code" : null);
+ if (typeof urlOption === "string") {
+ evalOptions.url = urlOption;
+ }
+
+ if (typeof options.lineNumber === "number") {
+ evalOptions.lineNumber = options.lineNumber;
+ }
+
+ updateConsoleInputEvaluation(dbg, webConsole);
+
+ let noSideEffectDebugger = null;
+ if (options.eager) {
+ noSideEffectDebugger = makeSideeffectFreeDebugger();
+ }
+
+ let result;
+ try {
+ result = getEvalResult(
+ dbg,
+ evalString,
+ evalOptions,
+ bindings,
+ frame,
+ dbgGlobal,
+ noSideEffectDebugger
+ );
+ } finally {
+ // We need to be absolutely sure that the sideeffect-free debugger's
+ // debuggees are removed because otherwise we risk them terminating
+ // execution of later code in the case of unexpected exceptions.
+ if (noSideEffectDebugger) {
+ noSideEffectDebugger.removeAllDebuggees();
+ }
+ }
+
+ // Attempt to initialize any declarations found in the evaluated string
+ // since they may now be stuck in an "initializing" state due to the
+ // error. Already-initialized bindings will be ignored.
+ if (!frame && result && "throw" in result) {
+ forceLexicalInitForVariableDeclarationsInThrowingExpression(
+ dbgGlobal,
+ string
+ );
+ }
+
+ return {
+ result,
+ // Retrieve the result of commands, if any ran
+ helperResult: helpers.getHelperResult(),
+ dbg,
+ frame,
+ dbgGlobal,
+ };
+};
+
+function getEvalResult(
+ dbg,
+ string,
+ evalOptions,
+ bindings,
+ frame,
+ dbgGlobal,
+ noSideEffectDebugger
+) {
+ if (noSideEffectDebugger) {
+ // Bug 1637883 demonstrated an issue where dbgGlobal was somehow in the
+ // same compartment as the Debugger, meaning it could not be debugged
+ // and thus cannot handle eager evaluation. In that case we skip execution.
+ if (!noSideEffectDebugger.hasDebuggee(dbgGlobal.unsafeDereference())) {
+ return null;
+ }
+
+ // When a sideeffect-free debugger has been created, we need to eval
+ // in the context of that debugger in order for the side-effect tracking
+ // to apply.
+ frame = frame ? noSideEffectDebugger.adoptFrame(frame) : null;
+ dbgGlobal = noSideEffectDebugger.adoptDebuggeeValue(dbgGlobal);
+ if (bindings) {
+ bindings = Object.keys(bindings).reduce((acc, key) => {
+ acc[key] = noSideEffectDebugger.adoptDebuggeeValue(bindings[key]);
+ return acc;
+ }, {});
+ }
+ }
+
+ let result;
+ if (frame) {
+ result = frame.evalWithBindings(string, bindings, evalOptions);
+ } else {
+ result = dbgGlobal.executeInGlobalWithBindings(
+ string,
+ bindings,
+ evalOptions
+ );
+ }
+ if (noSideEffectDebugger && result) {
+ if ("return" in result) {
+ result.return = dbg.adoptDebuggeeValue(result.return);
+ }
+ if ("throw" in result) {
+ result.throw = dbg.adoptDebuggeeValue(result.throw);
+ }
+ }
+ return result;
+}
+
+/**
+ * Force lexical initialization for let/const variables declared in a throwing expression.
+ * By spec, a lexical declaration is added to the *page-visible* global lexical environment
+ * for those variables, meaning they can't be redeclared (See Bug 1246215).
+ *
+ * This function gets the AST of the throwing expression to collect all the let/const
+ * declarations and call `forceLexicalInitializationByName`, which will initialize them
+ * to undefined, making it possible for them to be redeclared.
+ *
+ * @param {DebuggerObject} dbgGlobal
+ * @param {String} string: The expression that was evaluated and threw
+ * @returns
+ */
+function forceLexicalInitForVariableDeclarationsInThrowingExpression(
+ dbgGlobal,
+ string
+) {
+ // Reflect is not usable in workers, so return early to avoid logging an error
+ // to the console when loading it.
+ if (isWorker) {
+ return;
+ }
+
+ let ast;
+ // Parse errors will raise an exception. We can/should ignore the error
+ // since it's already being handled elsewhere and we are only interested
+ // in initializing bindings.
+ try {
+ ast = lazy.Reflect.parse(string);
+ } catch (e) {
+ return;
+ }
+
+ try {
+ for (const line of ast.body) {
+ // Only let and const declarations put bindings into an
+ // "initializing" state.
+ if (!(line.kind == "let" || line.kind == "const")) {
+ continue;
+ }
+
+ const identifiers = [];
+ for (const decl of line.declarations) {
+ switch (decl.id.type) {
+ case "Identifier":
+ // let foo = bar;
+ identifiers.push(decl.id.name);
+ break;
+ case "ArrayPattern":
+ // let [foo, bar] = [1, 2];
+ // let [foo=99, bar] = [1, 2];
+ for (const e of decl.id.elements) {
+ if (e.type == "Identifier") {
+ identifiers.push(e.name);
+ } else if (e.type == "AssignmentExpression") {
+ identifiers.push(e.left.name);
+ }
+ }
+ break;
+ case "ObjectPattern":
+ // let {bilbo, my} = {bilbo: "baggins", my: "precious"};
+ // let {blah: foo} = {blah: yabba()}
+ // let {blah: foo=99} = {blah: yabba()}
+ for (const prop of decl.id.properties) {
+ // key
+ if (prop.key?.type == "Identifier") {
+ identifiers.push(prop.key.name);
+ }
+ // value
+ if (prop.value?.type == "Identifier") {
+ identifiers.push(prop.value.name);
+ } else if (prop.value?.type == "AssignmentExpression") {
+ identifiers.push(prop.value.left.name);
+ } else if (prop.type === "SpreadExpression") {
+ identifiers.push(prop.expression.name);
+ }
+ }
+ break;
+ }
+ }
+
+ for (const name of identifiers) {
+ dbgGlobal.forceLexicalInitializationByName(name);
+ }
+ }
+ } catch (ex) {
+ console.error(
+ "Error in forceLexicalInitForVariableDeclarationsInThrowingExpression:",
+ ex
+ );
+ }
+}
+
+/**
+ * Creates a side-effect-free debugger instance
+ *
+ * @return object
+ * Side-effect-free debugger.
+ */
+function makeSideeffectFreeDebugger() {
+ // We ensure that the metadata for native functions is loaded before we
+ // initialize sideeffect-prevention because the data is lazy-loaded, and this
+ // logic can run inside of debuggee compartments because the
+ // "addAllGlobalsAsDebuggees" considers the vast majority of realms
+ // valid debuggees. Without this, eager-eval runs the risk of failing
+ // because building the list of valid native functions is itself a
+ // side-effectful operation because it needs to populate a
+ // module cache, among any number of other things.
+ ensureSideEffectFreeNatives();
+
+ // Note: It is critical for debuggee performance that we implement all of
+ // this debuggee tracking logic with a separate Debugger instance.
+ // Bug 1617666 arises otherwise if we set an onEnterFrame hook on the
+ // existing debugger object and then later clear it.
+ const dbg = new Debugger();
+ dbg.addAllGlobalsAsDebuggees();
+
+ const timeoutDuration = 100;
+ const endTime = Date.now() + timeoutDuration;
+ let count = 0;
+ function shouldCancel() {
+ // To keep the evaled code as quick as possible, we avoid querying the
+ // current time on ever single step and instead check every 100 steps
+ // as an arbitrary count that seemed to be "often enough".
+ return ++count % 100 === 0 && Date.now() > endTime;
+ }
+
+ const executedScripts = new Set();
+ const handler = {
+ hit: () => null,
+ };
+ dbg.onEnterFrame = frame => {
+ if (shouldCancel()) {
+ return null;
+ }
+ frame.onStep = () => {
+ if (shouldCancel()) {
+ return null;
+ }
+ return undefined;
+ };
+
+ const script = frame.script;
+
+ if (executedScripts.has(script)) {
+ return undefined;
+ }
+ executedScripts.add(script);
+
+ const offsets = script.getEffectfulOffsets();
+ for (const offset of offsets) {
+ script.setBreakpoint(offset, handler);
+ }
+
+ return undefined;
+ };
+
+ // The debugger only calls onNativeCall handlers on the debugger that is
+ // explicitly calling either eval, DebuggerObject.apply or DebuggerObject.call,
+ // so we need to add this hook on "dbg" even though the rest of our hooks work via "newDbg".
+ dbg.onNativeCall = (callee, reason) => {
+ try {
+ // Setters are always effectful. Natives called normally or called via
+ // getters are handled with an allowlist.
+ if (
+ (reason == "get" || reason == "call") &&
+ nativeIsEagerlyEvaluateable(callee)
+ ) {
+ // Returning undefined causes execution to continue normally.
+ return undefined;
+ }
+ } catch (err) {
+ DevToolsUtils.reportException(
+ "evalWithDebugger onNativeCall",
+ new Error("Unable to validate native function against allowlist")
+ );
+ }
+ // Returning null terminates the current evaluation.
+ return null;
+ };
+
+ return dbg;
+}
+
+// Native functions which are considered to be side effect free.
+let gSideEffectFreeNatives; // string => Array(Function)
+
+/**
+ * Generate gSideEffectFreeNatives map.
+ */
+function ensureSideEffectFreeNatives() {
+ if (gSideEffectFreeNatives) {
+ return;
+ }
+
+ const { natives: domNatives } = eagerFunctionAllowlist;
+
+ const natives = [
+ ...eagerEcmaAllowlist.functions,
+ ...eagerEcmaAllowlist.getters,
+
+ // Pull in all of the non-ECMAScript native functions that we want to
+ // allow as well.
+ ...domNatives,
+ ];
+
+ const map = new Map();
+ for (const n of natives) {
+ if (!map.has(n.name)) {
+ map.set(n.name, []);
+ }
+ map.get(n.name).push(n);
+ }
+
+ gSideEffectFreeNatives = map;
+}
+
+function nativeIsEagerlyEvaluateable(fn) {
+ if (fn.isBoundFunction) {
+ fn = fn.boundTargetFunction;
+ }
+
+ // We assume all DOM getters have no major side effect, and they are
+ // eagerly-evaluateable.
+ //
+ // JitInfo is used only by methods/accessors in WebIDL, and being
+ // "a getter with JitInfo" can be used as a condition to check if given
+ // function is DOM getter.
+ //
+ // This includes privileged interfaces in addition to standard web APIs.
+ if (fn.isNativeGetterWithJitInfo()) {
+ return true;
+ }
+
+ // Natives with certain names are always considered side effect free.
+ switch (fn.name) {
+ case "toString":
+ case "toLocaleString":
+ case "valueOf":
+ return true;
+ }
+
+ const natives = gSideEffectFreeNatives.get(fn.name);
+ return natives && natives.some(n => fn.isSameNative(n));
+}
+
+function updateConsoleInputEvaluation(dbg, webConsole) {
+ // Adopt webConsole._lastConsoleInputEvaluation value in the new debugger,
+ // to prevent "Debugger.Object belongs to a different Debugger" exceptions
+ // related to the $_ bindings if the debugger object is changed from the
+ // last evaluation.
+ if (webConsole._lastConsoleInputEvaluation) {
+ webConsole._lastConsoleInputEvaluation = dbg.adoptDebuggeeValue(
+ webConsole._lastConsoleInputEvaluation
+ );
+ }
+}
+
+function getEvalInput(string) {
+ const trimmedString = string.trim();
+ // The help function needs to be easy to guess, so we make the () optional.
+ if (trimmedString === "help" || trimmedString === "?") {
+ return "help()";
+ }
+ // we support Unix like syntax for commands if it is preceeded by `:`
+ if (isCommand(string)) {
+ try {
+ return formatCommand(string);
+ } catch (e) {
+ console.log(e);
+ return `throw "${e}"`;
+ }
+ }
+
+ // Add easter egg for console.mihai().
+ if (
+ trimmedString == "console.mihai()" ||
+ trimmedString == "console.mihai();"
+ ) {
+ return '"http://incompleteness.me/blog/2015/02/09/console-dot-mihai/"';
+ }
+ return string;
+}
+
+function getFrameDbg(options, webConsole) {
+ if (!options.frameActor) {
+ return { frame: null, dbg: webConsole.dbg };
+ }
+ // Find the Debugger.Frame of the given FrameActor.
+ const frameActor = webConsole.conn.getActor(options.frameActor);
+ if (frameActor) {
+ // If we've been given a frame actor in whose scope we should evaluate the
+ // expression, be sure to use that frame's Debugger (that is, the JavaScript
+ // debugger's Debugger) for the whole operation, not the console's Debugger.
+ // (One Debugger will treat a different Debugger's Debugger.Object instances
+ // as ordinary objects, not as references to be followed, so mixing
+ // debuggers causes strange behaviors.)
+ return { frame: frameActor.frame, dbg: frameActor.threadActor.dbg };
+ }
+ return DevToolsUtils.reportException(
+ "evalWithDebugger",
+ Error("The frame actor was not found: " + options.frameActor)
+ );
+}
+
+/**
+ * Get debugger object for given debugger and Web Console.
+ *
+ * @param object options
+ * See the `options` parameter of evalWithDebugger
+ * @param {Debugger} dbg
+ * Debugger object
+ * @param {WebConsoleActor} webConsole
+ * A reference to a webconsole actor which is used to get the target
+ * eval global and optionally the target actor
+ * @return object
+ * An object that holds the following properties:
+ * - bindSelf: (optional) the self object for the evaluation
+ * - dbgGlobal: the global object reference in the debugger
+ */
+function getDbgGlobal(options, dbg, webConsole) {
+ let evalGlobal = webConsole.evalGlobal;
+
+ if (options.innerWindowID) {
+ const window = Services.wm.getCurrentInnerWindowWithId(
+ options.innerWindowID
+ );
+
+ if (window) {
+ evalGlobal = window;
+ }
+ }
+
+ const dbgGlobal = dbg.makeGlobalObjectReference(evalGlobal);
+
+ // If we have an object to bind to |_self|, create a Debugger.Object
+ // referring to that object, belonging to dbg.
+ if (!options.selectedObjectActor) {
+ return { bindSelf: null, dbgGlobal };
+ }
+
+ // For objects related to console messages, they will be registered under the Target Actor
+ // instead of the WebConsoleActor. That's because console messages are resources and all resources
+ // are emitted by the Target Actor.
+ const actor =
+ webConsole.getActorByID(options.selectedObjectActor) ||
+ webConsole.parentActor.getActorByID(options.selectedObjectActor);
+
+ if (!actor) {
+ return { bindSelf: null, dbgGlobal };
+ }
+
+ const jsVal = actor instanceof LongStringActor ? actor.str : actor.rawValue();
+ if (!isObject(jsVal)) {
+ return { bindSelf: jsVal, dbgGlobal };
+ }
+
+ // If we use the makeDebuggeeValue method of jsVal's own global, then
+ // we'll get a D.O that sees jsVal as viewed from its own compartment -
+ // that is, without wrappers. The evalWithBindings call will then wrap
+ // jsVal appropriately for the evaluation compartment.
+ const bindSelf = dbgGlobal.makeDebuggeeValue(jsVal);
+ return { bindSelf, dbgGlobal };
+}
diff --git a/devtools/server/actors/webconsole/listeners/console-api.js b/devtools/server/actors/webconsole/listeners/console-api.js
new file mode 100644
index 0000000000..3e5d0bc52f
--- /dev/null
+++ b/devtools/server/actors/webconsole/listeners/console-api.js
@@ -0,0 +1,255 @@
+/* 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";
+
+const {
+ CONSOLE_WORKER_IDS,
+ WebConsoleUtils,
+} = require("resource://devtools/server/actors/webconsole/utils.js");
+
+// The window.console API observer
+
+/**
+ * The window.console API observer. This allows the window.console API messages
+ * to be sent to the remote Web Console instance.
+ *
+ * @constructor
+ * @param nsIDOMWindow window
+ * Optional - the window object for which we are created. This is used
+ * for filtering out messages that belong to other windows.
+ * @param Function handler
+ * This function is invoked with one argument, the Console API message that comes
+ * from the observer service, whenever a relevant console API call is received.
+ * @param object filteringOptions
+ * Optional - The filteringOptions that this listener should listen to:
+ * - addonId: filter console messages based on the addonId.
+ * - excludeMessagesBoundToWindow: Set to true to filter out messages that
+ * are bound to a specific window.
+ * - matchExactWindow: Set to true to match the messages on a specific window (when
+ * `window` is defined) and not on the whole window tree.
+ */
+class ConsoleAPIListener {
+ constructor(
+ window,
+ handler,
+ { addonId, excludeMessagesBoundToWindow, matchExactWindow } = {}
+ ) {
+ this.window = window;
+ this.handler = handler;
+ this.addonId = addonId;
+ this.excludeMessagesBoundToWindow = excludeMessagesBoundToWindow;
+ this.matchExactWindow = matchExactWindow;
+ if (this.window) {
+ this.innerWindowId = WebConsoleUtils.getInnerWindowId(this.window);
+ }
+ }
+
+ QueryInterface = ChromeUtils.generateQI([Ci.nsIObserver]);
+
+ /**
+ * The content window for which we listen to window.console API calls.
+ * @type nsIDOMWindow
+ */
+ window = null;
+
+ /**
+ * The function which is notified of window.console API calls. It is invoked with one
+ * argument: the console API call object that comes from the ConsoleAPIStorage service.
+ *
+ * @type function
+ */
+ handler = null;
+
+ /**
+ * The addonId that we listen for. If not null then only messages from this
+ * console will be returned.
+ */
+ addonId = null;
+
+ /**
+ * Initialize the window.console API listener.
+ */
+ init() {
+ const ConsoleAPIStorage = Cc[
+ "@mozilla.org/consoleAPI-storage;1"
+ ].getService(Ci.nsIConsoleAPIStorage);
+
+ // Note that the listener is process-wide. We will filter the messages as
+ // needed, see onConsoleAPILogEvent().
+ this.onConsoleAPILogEvent = this.onConsoleAPILogEvent.bind(this);
+ ConsoleAPIStorage.addLogEventListener(
+ this.onConsoleAPILogEvent,
+ // We create a principal here to get the privileged principal of this
+ // script. Note that this is importantly *NOT* the principal of the
+ // content we are observing, as that would not have access to the
+ // message object created in ConsoleAPIStorage.jsm's scope.
+ Cc["@mozilla.org/systemprincipal;1"].createInstance(Ci.nsIPrincipal)
+ );
+ }
+
+ /**
+ * The console API message listener. When messages are received from the
+ * ConsoleAPIStorage service we forward them to the remote Web Console instance.
+ *
+ * @param object message
+ * The message object receives from the ConsoleAPIStorage service.
+ */
+ onConsoleAPILogEvent(message) {
+ if (!this.handler) {
+ return;
+ }
+
+ // Here, wrappedJSObject is not a security wrapper but a property defined
+ // by the XPCOM component which allows us to unwrap the XPCOM interface and
+ // access the underlying JSObject.
+ const apiMessage = message.wrappedJSObject;
+
+ if (!this.isMessageRelevant(apiMessage)) {
+ return;
+ }
+
+ this.handler(apiMessage);
+ }
+
+ /**
+ * Given a message, return true if this window should show it and false
+ * if it should be ignored.
+ *
+ * @param message
+ * The message from the Storage Service
+ * @return bool
+ * Do we care about this message?
+ */
+ isMessageRelevant(message) {
+ const workerType = WebConsoleUtils.getWorkerType(message);
+
+ if (this.window && workerType === "ServiceWorker") {
+ // For messages from Service Workers, message.ID is the
+ // scope, which can be used to determine whether it's controlling
+ // a window.
+ const scope = message.ID;
+
+ if (!this.window.shouldReportForServiceWorkerScope(scope)) {
+ return false;
+ }
+ }
+
+ // innerID can be of different type:
+ // - a number if the message is bound to a specific window
+ // - a worker type ([Shared|Service]Worker) if the message comes from a worker
+ // - a JSM filename
+ // if we want to filter on a specific window, ignore all non-worker messages that
+ // don't have a proper window id (for now, we receive the worker messages from the
+ // main process so we still want to get them, although their innerID isn't a number).
+ if (!workerType && typeof message.innerID !== "number" && this.window) {
+ return false;
+ }
+
+ // Don't show ChromeWorker messages on WindowGlobal targets
+ if (workerType && this.window && message.chromeContext) {
+ return false;
+ }
+
+ if (typeof message.innerID == "number") {
+ if (
+ this.excludeMessagesBoundToWindow &&
+ // If innerID is 0, the message isn't actually bound to a window.
+ message.innerID
+ ) {
+ return false;
+ }
+
+ if (this.window) {
+ const matchesWindow = this.matchExactWindow
+ ? this.innerWindowId === message.innerID
+ : WebConsoleUtils.getInnerWindowIDsForFrames(this.window).includes(
+ message.innerID
+ );
+
+ if (!matchesWindow) {
+ // Not the same window!
+ return false;
+ }
+ }
+ }
+
+ if (this.addonId) {
+ // ConsoleAPI.jsm messages contains a consoleID, (and it is currently
+ // used in Addon SDK add-ons), the standard 'console' object
+ // (which is used in regular webpages and in WebExtensions pages)
+ // contains the originAttributes of the source document principal.
+
+ // Filtering based on the originAttributes used by
+ // the Console API object.
+ if (message.addonId == this.addonId) {
+ return true;
+ }
+
+ // Filtering based on the old-style consoleID property used by
+ // the legacy Console JSM module.
+ if (message.consoleID && message.consoleID == `addon/${this.addonId}`) {
+ return true;
+ }
+
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Get the cached messages for the current inner window and its (i)frames.
+ *
+ * @param boolean [includePrivate=false]
+ * Tells if you want to also retrieve messages coming from private
+ * windows. Defaults to false.
+ * @return array
+ * The array of cached messages.
+ */
+ getCachedMessages(includePrivate = false) {
+ let messages = [];
+ const ConsoleAPIStorage = Cc[
+ "@mozilla.org/consoleAPI-storage;1"
+ ].getService(Ci.nsIConsoleAPIStorage);
+
+ // if !this.window, we're in a browser console. Retrieve all events
+ // for filtering based on privacy.
+ if (!this.window) {
+ messages = ConsoleAPIStorage.getEvents();
+ } else if (this.matchExactWindow) {
+ messages = ConsoleAPIStorage.getEvents(this.innerWindowId);
+ } else {
+ WebConsoleUtils.getInnerWindowIDsForFrames(this.window).forEach(id => {
+ messages = messages.concat(ConsoleAPIStorage.getEvents(id));
+ });
+ }
+
+ CONSOLE_WORKER_IDS.forEach(id => {
+ messages = messages.concat(ConsoleAPIStorage.getEvents(id));
+ });
+
+ messages = messages.filter(msg => {
+ return this.isMessageRelevant(msg);
+ });
+
+ if (includePrivate) {
+ return messages;
+ }
+
+ return messages.filter(m => !m.private);
+ }
+
+ /**
+ * Destroy the console API listener.
+ */
+ destroy() {
+ const ConsoleAPIStorage = Cc[
+ "@mozilla.org/consoleAPI-storage;1"
+ ].getService(Ci.nsIConsoleAPIStorage);
+ ConsoleAPIStorage.removeLogEventListener(this.onConsoleAPILogEvent);
+ this.window = this.handler = null;
+ }
+}
+exports.ConsoleAPIListener = ConsoleAPIListener;
diff --git a/devtools/server/actors/webconsole/listeners/console-file-activity.js b/devtools/server/actors/webconsole/listeners/console-file-activity.js
new file mode 100644
index 0000000000..7e5ae0d1a8
--- /dev/null
+++ b/devtools/server/actors/webconsole/listeners/console-file-activity.js
@@ -0,0 +1,126 @@
+/* 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";
+
+/**
+ * A WebProgressListener that listens for file loads.
+ *
+ * @constructor
+ * @param object window
+ * The window for which we need to track file loads.
+ * @param object owner
+ * The listener owner which needs to implement:
+ * - onFileActivity(aFileURI)
+ */
+function ConsoleFileActivityListener(window, owner) {
+ this.window = window;
+ this.owner = owner;
+}
+exports.ConsoleFileActivityListener = ConsoleFileActivityListener;
+
+ConsoleFileActivityListener.prototype = {
+ /**
+ * Tells if the console progress listener is initialized or not.
+ * @private
+ * @type boolean
+ */
+ _initialized: false,
+
+ _webProgress: null,
+
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIWebProgressListener",
+ "nsISupportsWeakReference",
+ ]),
+
+ /**
+ * Initialize the ConsoleFileActivityListener.
+ * @private
+ */
+ _init() {
+ if (this._initialized) {
+ return;
+ }
+
+ this._webProgress = this.window.docShell.QueryInterface(Ci.nsIWebProgress);
+ this._webProgress.addProgressListener(
+ this,
+ Ci.nsIWebProgress.NOTIFY_STATE_ALL
+ );
+
+ this._initialized = true;
+ },
+
+ /**
+ * Start a monitor/tracker related to the current nsIWebProgressListener
+ * instance.
+ */
+ startMonitor() {
+ this._init();
+ },
+
+ /**
+ * Stop monitoring.
+ */
+ stopMonitor() {
+ this.destroy();
+ },
+
+ onStateChange(progress, request, state, status) {
+ if (!this.owner) {
+ return;
+ }
+
+ this._checkFileActivity(progress, request, state, status);
+ },
+
+ /**
+ * Check if there is any file load, given the arguments of
+ * nsIWebProgressListener.onStateChange. If the state change tells that a file
+ * URI has been loaded, then the remote Web Console instance is notified.
+ * @private
+ */
+ _checkFileActivity(progress, request, state, status) {
+ if (!(state & Ci.nsIWebProgressListener.STATE_START)) {
+ return;
+ }
+
+ let uri = null;
+ if (request instanceof Ci.imgIRequest) {
+ const imgIRequest = request.QueryInterface(Ci.imgIRequest);
+ uri = imgIRequest.URI;
+ } else if (request instanceof Ci.nsIChannel) {
+ const nsIChannel = request.QueryInterface(Ci.nsIChannel);
+ uri = nsIChannel.URI;
+ }
+
+ if (!uri || (!uri.schemeIs("file") && !uri.schemeIs("ftp"))) {
+ return;
+ }
+
+ this.owner.onFileActivity(uri.spec);
+ },
+
+ /**
+ * Destroy the ConsoleFileActivityListener.
+ */
+ destroy() {
+ if (!this._initialized) {
+ return;
+ }
+
+ this._initialized = false;
+
+ try {
+ this._webProgress.removeProgressListener(this);
+ } catch (ex) {
+ // This can throw during browser shutdown.
+ }
+
+ this._webProgress = null;
+ this.window = null;
+ this.owner = null;
+ },
+};
diff --git a/devtools/server/actors/webconsole/listeners/console-reflow.js b/devtools/server/actors/webconsole/listeners/console-reflow.js
new file mode 100644
index 0000000000..8404a70b4a
--- /dev/null
+++ b/devtools/server/actors/webconsole/listeners/console-reflow.js
@@ -0,0 +1,90 @@
+/* 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";
+
+/**
+ * A ReflowObserver that listens for reflow events from the page.
+ * Implements nsIReflowObserver.
+ *
+ * @constructor
+ * @param object window
+ * The window for which we need to track reflow.
+ * @param object owner
+ * The listener owner which needs to implement:
+ * - onReflowActivity(reflowInfo)
+ */
+
+function ConsoleReflowListener(window, listener) {
+ this.docshell = window.docShell;
+ this.listener = listener;
+ this.docshell.addWeakReflowObserver(this);
+}
+
+exports.ConsoleReflowListener = ConsoleReflowListener;
+
+ConsoleReflowListener.prototype = {
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIReflowObserver",
+ "nsISupportsWeakReference",
+ ]),
+ docshell: null,
+ listener: null,
+
+ /**
+ * Forward reflow event to listener.
+ *
+ * @param DOMHighResTimeStamp start
+ * @param DOMHighResTimeStamp end
+ * @param boolean interruptible
+ */
+ sendReflow(start, end, interruptible) {
+ const frame = Components.stack.caller.caller;
+
+ let filename = frame ? frame.filename : null;
+
+ if (filename) {
+ // Because filename could be of the form "xxx.js -> xxx.js -> xxx.js",
+ // we only take the last part.
+ filename = filename.split(" ").pop();
+ }
+
+ this.listener.onReflowActivity({
+ interruptible,
+ start,
+ end,
+ sourceURL: filename,
+ sourceLine: frame ? frame.lineNumber : null,
+ functionName: frame ? frame.name : null,
+ });
+ },
+
+ /**
+ * On uninterruptible reflow
+ *
+ * @param DOMHighResTimeStamp start
+ * @param DOMHighResTimeStamp end
+ */
+ reflow(start, end) {
+ this.sendReflow(start, end, false);
+ },
+
+ /**
+ * On interruptible reflow
+ *
+ * @param DOMHighResTimeStamp start
+ * @param DOMHighResTimeStamp end
+ */
+ reflowInterruptible(start, end) {
+ this.sendReflow(start, end, true);
+ },
+
+ /**
+ * Unregister listener.
+ */
+ destroy() {
+ this.docshell.removeWeakReflowObserver(this);
+ this.listener = this.docshell = null;
+ },
+};
diff --git a/devtools/server/actors/webconsole/listeners/console-service.js b/devtools/server/actors/webconsole/listeners/console-service.js
new file mode 100644
index 0000000000..11ced5611f
--- /dev/null
+++ b/devtools/server/actors/webconsole/listeners/console-service.js
@@ -0,0 +1,193 @@
+/* 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";
+
+const {
+ isWindowIncluded,
+} = require("resource://devtools/shared/layout/utils.js");
+const {
+ WebConsoleUtils,
+} = require("resource://devtools/server/actors/webconsole/utils.js");
+
+// The page errors listener
+
+/**
+ * The nsIConsoleService listener. This is used to send all of the console
+ * messages (JavaScript, CSS and more) to the remote Web Console instance.
+ *
+ * @constructor
+ * @param nsIDOMWindow [window]
+ * Optional - the window object for which we are created. This is used
+ * for filtering out messages that belong to other windows.
+ * @param Function handler
+ * This function is invoked with one argument, the nsIConsoleMessage, whenever a
+ * relevant message is received.
+ * @param object filteringOptions
+ * Optional - The filteringOptions that this listener should listen to:
+ * - matchExactWindow: Set to true to match the messages on a specific window (when
+ * `window` is defined) and not on the whole window tree.
+ */
+class ConsoleServiceListener {
+ constructor(window, handler, { matchExactWindow } = {}) {
+ this.window = window;
+ this.handler = handler;
+ this.matchExactWindow = matchExactWindow;
+ }
+
+ QueryInterface = ChromeUtils.generateQI([Ci.nsIConsoleListener]);
+
+ /**
+ * The content window for which we listen to page errors.
+ * @type nsIDOMWindow
+ */
+ window = null;
+
+ /**
+ * The function which is notified of messages from the console service.
+ * @type function
+ */
+ handler = null;
+
+ /**
+ * Initialize the nsIConsoleService listener.
+ */
+ init() {
+ Services.console.registerListener(this);
+ }
+
+ /**
+ * The nsIConsoleService observer. This method takes all the script error
+ * messages belonging to the current window and sends them to the remote Web
+ * Console instance.
+ *
+ * @param nsIConsoleMessage message
+ * The message object coming from the nsIConsoleService.
+ */
+ observe(message) {
+ if (!this.handler) {
+ return;
+ }
+
+ if (this.window) {
+ if (
+ !(message instanceof Ci.nsIScriptError) ||
+ !message.outerWindowID ||
+ !this.isCategoryAllowed(message.category)
+ ) {
+ return;
+ }
+
+ const errorWindow = Services.wm.getOuterWindowWithId(
+ message.outerWindowID
+ );
+
+ if (!errorWindow) {
+ return;
+ }
+
+ if (this.matchExactWindow && this.window !== errorWindow) {
+ return;
+ }
+
+ if (!isWindowIncluded(this.window, errorWindow)) {
+ return;
+ }
+ }
+
+ // Don't display messages triggered by eager evaluation.
+ if (message.sourceName === "debugger eager eval code") {
+ return;
+ }
+ this.handler(message);
+ }
+
+ /**
+ * Check if the given message category is allowed to be tracked or not.
+ * We ignore chrome-originating errors as we only care about content.
+ *
+ * @param string category
+ * The message category you want to check.
+ * @return boolean
+ * True if the category is allowed to be logged, false otherwise.
+ */
+ isCategoryAllowed(category) {
+ if (!category) {
+ return false;
+ }
+
+ switch (category) {
+ case "XPConnect JavaScript":
+ case "component javascript":
+ case "chrome javascript":
+ case "chrome registration":
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Get the cached page errors for the current inner window and its (i)frames.
+ *
+ * @param boolean [includePrivate=false]
+ * Tells if you want to also retrieve messages coming from private
+ * windows. Defaults to false.
+ * @return array
+ * The array of cached messages. Each element is an nsIScriptError or
+ * an nsIConsoleMessage
+ */
+ getCachedMessages(includePrivate = false) {
+ const errors = Services.console.getMessageArray() || [];
+
+ // if !this.window, we're in a browser console. Still need to filter
+ // private messages.
+ if (!this.window) {
+ return errors.filter(error => {
+ if (error instanceof Ci.nsIScriptError) {
+ if (!includePrivate && error.isFromPrivateWindow) {
+ return false;
+ }
+ }
+
+ return true;
+ });
+ }
+
+ const ids = this.matchExactWindow
+ ? [WebConsoleUtils.getInnerWindowId(this.window)]
+ : WebConsoleUtils.getInnerWindowIDsForFrames(this.window);
+
+ return errors.filter(error => {
+ if (error instanceof Ci.nsIScriptError) {
+ if (!includePrivate && error.isFromPrivateWindow) {
+ return false;
+ }
+ if (
+ ids &&
+ (!ids.includes(error.innerWindowID) ||
+ !this.isCategoryAllowed(error.category))
+ ) {
+ return false;
+ }
+ } else if (ids?.[0]) {
+ // If this is not an nsIScriptError and we need to do window-based
+ // filtering we skip this message.
+ return false;
+ }
+
+ return true;
+ });
+ }
+
+ /**
+ * Remove the nsIConsoleService listener.
+ */
+ destroy() {
+ Services.console.unregisterListener(this);
+ this.handler = this.window = null;
+ }
+}
+
+exports.ConsoleServiceListener = ConsoleServiceListener;
diff --git a/devtools/server/actors/webconsole/listeners/document-events.js b/devtools/server/actors/webconsole/listeners/document-events.js
new file mode 100644
index 0000000000..1c1f926436
--- /dev/null
+++ b/devtools/server/actors/webconsole/listeners/document-events.js
@@ -0,0 +1,247 @@
+/* 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/. */
+
+// XPCNativeWrapper is not defined globally in ESLint as it may be going away.
+// See bug 1481337.
+/* global XPCNativeWrapper */
+
+"use strict";
+
+const EventEmitter = require("resource://devtools/shared/event-emitter.js");
+
+/**
+ * About "navigationStart - ${WILL_NAVIGATE_TIME_SHIFT}ms":
+ * Unfortunately dom-loading's navigationStart timestamp is older than the navigationStart we receive from will-navigate.
+ *
+ * That's because we record `navigationStart` before will-navigate code is called.
+ * And will-navigate code don't have access to performance.timing.navigationStart that dom-loading is using.
+ * The `performance.timing.navigationStart` is recorded earlier from `DocumentLoadListener.SetNavigating`, here:
+ * https://searchfox.org/mozilla-central/rev/9b430bb1a11d7152cab2af4574f451ffb906b052/netwerk/ipc/DocumentLoadListener.cpp#907-908
+ * https://searchfox.org/mozilla-central/rev/9b430bb1a11d7152cab2af4574f451ffb906b052/netwerk/ipc/DocumentLoadListener.cpp#820-823
+ * While this function is being called via `nsIWebProgressListener.onStateChange`, here:
+ * https://searchfox.org/mozilla-central/rev/9b430bb1a11d7152cab2af4574f451ffb906b052/netwerk/ipc/DocumentLoadListener.cpp#934-939
+ * And we record the navigationStart timestamp from onStateChange by using Date.now(), which is more recent
+ * than performance.timing.navigationStart.
+ *
+ * We do this workaround because all DOCUMENT_EVENT comes with a "time" timestamp.
+ * Each event relates to a particular event in the lifecycle of documents and are supposed to follow a particular order:
+ * - will-navigate (on the previous target)
+ * - dom-loading (on the new target)
+ * - dom-interactive
+ * - dom-complete
+ * And some tests are asserting this.
+ */
+const WILL_NAVIGATE_TIME_SHIFT = 20;
+exports.WILL_NAVIGATE_TIME_SHIFT = WILL_NAVIGATE_TIME_SHIFT;
+
+/**
+ * Forward `DOMContentLoaded` and `load` events with precise timing
+ * of when events happened according to window.performance numbers.
+ *
+ * @constructor
+ * @param WindowGlobalTarget targetActor
+ */
+function DocumentEventsListener(targetActor) {
+ this.targetActor = targetActor;
+
+ EventEmitter.decorate(this);
+ this.onWillNavigate = this.onWillNavigate.bind(this);
+ this.onWindowReady = this.onWindowReady.bind(this);
+ this.onContentLoaded = this.onContentLoaded.bind(this);
+ this.onLoad = this.onLoad.bind(this);
+}
+
+exports.DocumentEventsListener = DocumentEventsListener;
+
+DocumentEventsListener.prototype = {
+ listen() {
+ // When EFT is enabled, the Target Actor won't dispatch any will-navigate/window-ready event
+ // Instead listen to WebProgressListener interface directly, so that we can later drop the whole
+ // DebuggerProgressListener interface in favor of this class.
+ // Also, do not wait for "load" event as it can be blocked in case of error during the load
+ // or when calling window.stop(). We still want to emit "dom-complete" in these scenarios.
+ if (this.targetActor.ignoreSubFrames) {
+ // Ignore listening to anything if the page is already fully loaded.
+ // This can be the case when opening DevTools against an already loaded page
+ // or when doing bfcache navigations.
+ if (this.targetActor.window.document.readyState != "complete") {
+ this.webProgress = this.targetActor.docShell
+ .QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIWebProgress);
+ this.webProgress.addProgressListener(
+ this,
+ Ci.nsIWebProgress.NOTIFY_STATE_WINDOW |
+ Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT
+ );
+ }
+ } else {
+ // Listen to will-navigate and do not emit a fake one as we only care about upcoming navigation
+ this.targetActor.on("will-navigate", this.onWillNavigate);
+
+ // Listen to window-ready and then fake one in order to notify about dom-loading for the existing document
+ this.targetActor.on("window-ready", this.onWindowReady);
+ }
+ // The target actor already emitted a window-ready for the top document when instantiating.
+ // So fake one for the top document right away.
+ this.onWindowReady({
+ window: this.targetActor.window,
+ isTopLevel: true,
+ });
+ },
+
+ onWillNavigate({
+ window,
+ isTopLevel,
+ newURI,
+ navigationStart,
+ isFrameSwitching,
+ }) {
+ // Ignore iframes
+ if (!isTopLevel) {
+ return;
+ }
+
+ this.emit("will-navigate", {
+ time: navigationStart - WILL_NAVIGATE_TIME_SHIFT,
+ newURI,
+ isFrameSwitching,
+ });
+ },
+
+ onWindowReady({ window, isTopLevel, isFrameSwitching }) {
+ // Ignore iframes
+ if (!isTopLevel) {
+ return;
+ }
+
+ const time = window.performance.timing.navigationStart;
+
+ this.emit("dom-loading", {
+ time,
+ isFrameSwitching,
+ });
+
+ const { readyState } = window.document;
+ if (readyState != "interactive" && readyState != "complete") {
+ // When EFT is enabled, we track this event via the WebProgressListener interface.
+ if (!this.targetActor.ignoreSubFrames) {
+ window.addEventListener(
+ "DOMContentLoaded",
+ e => this.onContentLoaded(e, isFrameSwitching),
+ {
+ once: true,
+ }
+ );
+ }
+ } else {
+ this.onContentLoaded({ target: window.document }, isFrameSwitching);
+ }
+ if (readyState != "complete") {
+ // When EFT is enabled, we track the load event via the WebProgressListener interface.
+ if (!this.targetActor.ignoreSubFrames) {
+ window.addEventListener("load", e => this.onLoad(e, isFrameSwitching), {
+ once: true,
+ });
+ }
+ } else {
+ this.onLoad({ target: window.document }, isFrameSwitching);
+ }
+ },
+
+ onContentLoaded(event, isFrameSwitching) {
+ if (this.destroyed) {
+ return;
+ }
+ // milliseconds since the UNIX epoch, when the parser finished its work
+ // on the main document, that is when its Document.readyState changes to
+ // 'interactive' and the corresponding readystatechange event is thrown
+ const window = event.target.defaultView;
+ const time = window.performance.timing.domInteractive;
+ this.emit("dom-interactive", { time, isFrameSwitching });
+ },
+
+ onLoad(event, isFrameSwitching) {
+ if (this.destroyed) {
+ return;
+ }
+ // milliseconds since the UNIX epoch, when the parser finished its work
+ // on the main document, that is when its Document.readyState changes to
+ // 'complete' and the corresponding readystatechange event is thrown
+ const window = event.target.defaultView;
+ const time = window.performance.timing.domComplete;
+ this.emit("dom-complete", {
+ time,
+ isFrameSwitching,
+ hasNativeConsoleAPI: this.hasNativeConsoleAPI(window),
+ });
+ },
+
+ onStateChange(progress, request, flag, status) {
+ progress.QueryInterface(Ci.nsIDocShell);
+ // Ignore destroyed, or progress for same-process iframes
+ if (progress.isBeingDestroyed() || progress != this.webProgress) {
+ return;
+ }
+
+ const isStop = flag & Ci.nsIWebProgressListener.STATE_STOP;
+ const isDocument = flag & Ci.nsIWebProgressListener.STATE_IS_DOCUMENT;
+ const isWindow = flag & Ci.nsIWebProgressListener.STATE_IS_WINDOW;
+ const window = progress.DOMWindow;
+ if (isDocument && isStop) {
+ const time = window.performance.timing.domInteractive;
+ this.emit("dom-interactive", { time });
+ } else if (isWindow && isStop) {
+ const time = window.performance.timing.domComplete;
+ this.emit("dom-complete", {
+ time,
+ hasNativeConsoleAPI: this.hasNativeConsoleAPI(window),
+ });
+ }
+ },
+
+ /**
+ * Tells if the window.console object is native or overwritten by script in
+ * the page.
+ *
+ * @param nsIDOMWindow window
+ * The window object you want to check.
+ * @return boolean
+ * True if the window.console object is native, or false otherwise.
+ */
+ hasNativeConsoleAPI(window) {
+ 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 === true;
+ }
+ } catch (ex) {
+ // ignore
+ }
+ return isNative;
+ },
+
+ destroy() {
+ // Also use a flag to silent onContentLoad and onLoad events
+ this.destroyed = true;
+ this.targetActor.off("will-navigate", this.onWillNavigate);
+ this.targetActor.off("window-ready", this.onWindowReady);
+ if (this.webProgress) {
+ this.webProgress.removeProgressListener(
+ this,
+ Ci.nsIWebProgress.NOTIFY_STATE_WINDOW |
+ Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT
+ );
+ }
+ },
+
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIWebProgressListener",
+ "nsISupportsWeakReference",
+ ]),
+};
diff --git a/devtools/server/actors/webconsole/listeners/moz.build b/devtools/server/actors/webconsole/listeners/moz.build
new file mode 100644
index 0000000000..089de4a087
--- /dev/null
+++ b/devtools/server/actors/webconsole/listeners/moz.build
@@ -0,0 +1,13 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+DevToolsModules(
+ "console-api.js",
+ "console-file-activity.js",
+ "console-reflow.js",
+ "console-service.js",
+ "document-events.js",
+)
diff --git a/devtools/server/actors/webconsole/moz.build b/devtools/server/actors/webconsole/moz.build
new file mode 100644
index 0000000000..58d8a70211
--- /dev/null
+++ b/devtools/server/actors/webconsole/moz.build
@@ -0,0 +1,20 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+DIRS += [
+ "commands",
+ "listeners",
+]
+
+DevToolsModules(
+ "eager-ecma-allowlist.js",
+ "eager-function-allowlist.js",
+ "eval-with-debugger.js",
+ "utils.js",
+ "webidl-pure-allowlist.js",
+ "webidl-unsafe-getters-names.js",
+ "worker-listeners.js",
+)
diff --git a/devtools/server/actors/webconsole/utils.js b/devtools/server/actors/webconsole/utils.js
new file mode 100644
index 0000000000..2834696944
--- /dev/null
+++ b/devtools/server/actors/webconsole/utils.js
@@ -0,0 +1,160 @@
+/* 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";
+
+const CONSOLE_WORKER_IDS = (exports.CONSOLE_WORKER_IDS = new Set([
+ "SharedWorker",
+ "ServiceWorker",
+ "Worker",
+]));
+
+var WebConsoleUtils = {
+ /**
+ * Given a message, return one of CONSOLE_WORKER_IDS if it matches
+ * one of those.
+ *
+ * @return string
+ */
+ getWorkerType(message) {
+ const innerID = message?.innerID;
+ return CONSOLE_WORKER_IDS.has(innerID) ? innerID : null;
+ },
+
+ /**
+ * Gets the ID of the inner window of this DOM window.
+ *
+ * @param nsIDOMWindow window
+ * @return integer|null
+ * Inner ID for the given window, null if we can't access it.
+ */
+ getInnerWindowId(window) {
+ // Might throw with SecurityError: Permission denied to access property
+ // "windowGlobalChild" on cross-origin object.
+ try {
+ return window.windowGlobalChild.innerWindowId;
+ } catch (e) {
+ return null;
+ }
+ },
+
+ /**
+ * Recursively gather a list of inner window ids given a
+ * top level window.
+ *
+ * @param nsIDOMWindow window
+ * @return Array
+ * list of inner window ids.
+ */
+ getInnerWindowIDsForFrames(window) {
+ const innerWindowID = this.getInnerWindowId(window);
+ if (innerWindowID === null) {
+ return [];
+ }
+
+ let ids = [innerWindowID];
+
+ if (window.frames) {
+ for (let i = 0; i < window.frames.length; i++) {
+ const frame = window.frames[i];
+ ids = ids.concat(this.getInnerWindowIDsForFrames(frame));
+ }
+ }
+
+ return ids;
+ },
+
+ /**
+ * Create a grip for the given value. If the value is an object,
+ * an object wrapper will be created.
+ *
+ * @param mixed value
+ * The value you want to create a grip for, before sending it to the
+ * client.
+ * @param function objectWrapper
+ * If the value is an object then the objectWrapper function is
+ * invoked to give us an object grip. See this.getObjectGrip().
+ * @return mixed
+ * The value grip.
+ */
+ createValueGrip(value, objectWrapper) {
+ switch (typeof value) {
+ case "boolean":
+ return value;
+ case "string":
+ return objectWrapper(value);
+ case "number":
+ if (value === Infinity) {
+ return { type: "Infinity" };
+ } else if (value === -Infinity) {
+ return { type: "-Infinity" };
+ } else if (Number.isNaN(value)) {
+ return { type: "NaN" };
+ } else if (!value && 1 / value === -Infinity) {
+ return { type: "-0" };
+ }
+ return value;
+ case "undefined":
+ return { type: "undefined" };
+ case "object":
+ if (value === null) {
+ return { type: "null" };
+ }
+ // Fall through.
+ case "function":
+ case "record":
+ case "tuple":
+ return objectWrapper(value);
+ default:
+ console.error(
+ "Failed to provide a grip for value of " + typeof value + ": " + value
+ );
+ return null;
+ }
+ },
+
+ /**
+ * Remove any frames in a stack that are above a debugger-triggered evaluation
+ * and will correspond with devtools server code, which we never want to show
+ * to the user.
+ *
+ * @param array stack
+ * An array of frames, with the topmost first, and each of which has a
+ * 'filename' property.
+ * @return array
+ * An array of stack frames with any devtools server frames removed.
+ * The original array is not modified.
+ */
+ removeFramesAboveDebuggerEval(stack) {
+ const debuggerEvalFilename = "debugger eval code";
+
+ // Remove any frames for server code above the last debugger eval frame.
+ const evalIndex = stack.findIndex(({ filename }, idx, arr) => {
+ const nextFrame = arr[idx + 1];
+ return (
+ filename == debuggerEvalFilename &&
+ (!nextFrame || nextFrame.filename !== debuggerEvalFilename)
+ );
+ });
+ if (evalIndex != -1) {
+ return stack.slice(0, evalIndex + 1);
+ }
+
+ // In some cases (e.g. evaluated expression with SyntaxError), we might not have a
+ // "debugger eval code" frame but still have internal ones. If that's the case, we
+ // return null as the end user shouldn't see those frames.
+ if (
+ stack.some(
+ ({ filename }) =>
+ filename && filename.startsWith("resource://devtools/")
+ )
+ ) {
+ return null;
+ }
+
+ return stack;
+ },
+};
+
+exports.WebConsoleUtils = WebConsoleUtils;
diff --git a/devtools/server/actors/webconsole/webidl-pure-allowlist.js b/devtools/server/actors/webconsole/webidl-pure-allowlist.js
new file mode 100644
index 0000000000..3db5a14da1
--- /dev/null
+++ b/devtools/server/actors/webconsole/webidl-pure-allowlist.js
@@ -0,0 +1,87 @@
+/* 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/. */
+
+// This file is automatically generated by the GenerateDataFromWebIdls.py
+// script. Do not modify it manually.
+"use strict";
+
+module.exports = {
+ DOMTokenList: {
+ prototype: ["item", "contains"],
+ },
+ Document: {
+ prototype: [
+ "getSelection",
+ "hasStorageAccess",
+ "getElementsByTagName",
+ "getElementsByTagNameNS",
+ "getElementsByClassName",
+ "getElementById",
+ "getElementsByName",
+ "querySelector",
+ "querySelectorAll",
+ "createNSResolver",
+ ],
+ },
+ Element: {
+ prototype: [
+ "getAttributeNames",
+ "getAttribute",
+ "getAttributeNS",
+ "hasAttribute",
+ "hasAttributeNS",
+ "hasAttributes",
+ "closest",
+ "matches",
+ "webkitMatchesSelector",
+ "getElementsByTagName",
+ "getElementsByTagNameNS",
+ "getElementsByClassName",
+ "mozMatchesSelector",
+ "querySelector",
+ "querySelectorAll",
+ "getAsFlexContainer",
+ "getGridFragments",
+ "hasGridFragments",
+ "getElementsWithGrid",
+ ],
+ },
+ FormData: {
+ prototype: ["entries", "keys", "values"],
+ },
+ Headers: {
+ prototype: ["entries", "keys", "values"],
+ },
+ Node: {
+ prototype: [
+ "getRootNode",
+ "hasChildNodes",
+ "isSameNode",
+ "isEqualNode",
+ "compareDocumentPosition",
+ "contains",
+ "lookupPrefix",
+ "lookupNamespaceURI",
+ "isDefaultNamespace",
+ ],
+ },
+ Performance: {
+ prototype: ["now"],
+ },
+ Range: {
+ prototype: [
+ "isPointInRange",
+ "comparePoint",
+ "intersectsNode",
+ "getClientRects",
+ "getBoundingClientRect",
+ ],
+ },
+ Selection: {
+ prototype: ["getRangeAt", "containsNode"],
+ },
+ URLSearchParams: {
+ prototype: ["entries", "keys", "values"],
+ },
+};
diff --git a/devtools/server/actors/webconsole/webidl-unsafe-getters-names.js b/devtools/server/actors/webconsole/webidl-unsafe-getters-names.js
new file mode 100644
index 0000000000..eb571c7545
--- /dev/null
+++ b/devtools/server/actors/webconsole/webidl-unsafe-getters-names.js
@@ -0,0 +1,20 @@
+/* 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/. */
+
+// This file is automatically generated by the GenerateDataFromWebIdls.py
+// script. Do not modify it manually.
+"use strict";
+
+module.exports = [
+ "InstallTrigger",
+ "farthestViewportElement",
+ "mozPreservesPitch",
+ "mozPressure",
+ "nearestViewportElement",
+ "onmouseenter",
+ "onmouseleave",
+ "onmozfullscreenchange",
+ "onmozfullscreenerror",
+ "onreadystatechange",
+];
diff --git a/devtools/server/actors/webconsole/worker-listeners.js b/devtools/server/actors/webconsole/worker-listeners.js
new file mode 100644
index 0000000000..6861c6da62
--- /dev/null
+++ b/devtools/server/actors/webconsole/worker-listeners.js
@@ -0,0 +1,35 @@
+/* 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/. */
+
+/* global setConsoleEventHandler, retrieveConsoleEvents */
+
+"use strict";
+
+// This file is loaded on the server side for worker debugging.
+// Since the server is running in the worker thread, it doesn't
+// have access to Services / Components but the listeners defined here
+// are imported by webconsole-utils and used for the webconsole actor.
+class ConsoleAPIListener {
+ constructor(window, listener, consoleID) {
+ this.window = window;
+ this.listener = listener;
+ this.consoleID = consoleID;
+ this.observe = this.observe.bind(this);
+ }
+
+ init() {
+ setConsoleEventHandler(this.observe);
+ }
+ destroy() {
+ setConsoleEventHandler(null);
+ }
+ observe(message) {
+ this.listener(message.wrappedJSObject);
+ }
+ getCachedMessages() {
+ return retrieveConsoleEvents();
+ }
+}
+
+exports.ConsoleAPIListener = ConsoleAPIListener;