summaryrefslogtreecommitdiffstats
path: root/devtools/server/actors/webconsole
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/server/actors/webconsole')
-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
17 files changed, 3000 insertions, 0 deletions
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;