summaryrefslogtreecommitdiffstats
path: root/devtools/server/actors/resources
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/server/actors/resources')
-rw-r--r--devtools/server/actors/resources/console-messages.js302
-rw-r--r--devtools/server/actors/resources/css-changes.js42
-rw-r--r--devtools/server/actors/resources/css-messages.js202
-rw-r--r--devtools/server/actors/resources/document-event.js112
-rw-r--r--devtools/server/actors/resources/error-messages.js192
-rw-r--r--devtools/server/actors/resources/extensions-backgroundscript-status.js68
-rw-r--r--devtools/server/actors/resources/index.js451
-rw-r--r--devtools/server/actors/resources/last-private-context-exit.js46
-rw-r--r--devtools/server/actors/resources/moz.build42
-rw-r--r--devtools/server/actors/resources/network-events-content.js266
-rw-r--r--devtools/server/actors/resources/network-events-stacktraces.js214
-rw-r--r--devtools/server/actors/resources/network-events.js414
-rw-r--r--devtools/server/actors/resources/parent-process-document-event.js174
-rw-r--r--devtools/server/actors/resources/platform-messages.js60
-rw-r--r--devtools/server/actors/resources/reflow.js63
-rw-r--r--devtools/server/actors/resources/server-sent-events.js135
-rw-r--r--devtools/server/actors/resources/sources.js97
-rw-r--r--devtools/server/actors/resources/storage-cache.js22
-rw-r--r--devtools/server/actors/resources/storage-cookie.js22
-rw-r--r--devtools/server/actors/resources/storage-extension.js30
-rw-r--r--devtools/server/actors/resources/storage-indexed-db.js22
-rw-r--r--devtools/server/actors/resources/storage-local-storage.js22
-rw-r--r--devtools/server/actors/resources/storage-session-storage.js22
-rw-r--r--devtools/server/actors/resources/storage/cache.js195
-rw-r--r--devtools/server/actors/resources/storage/cookies.js554
-rw-r--r--devtools/server/actors/resources/storage/extension-storage.js491
-rw-r--r--devtools/server/actors/resources/storage/index.js404
-rw-r--r--devtools/server/actors/resources/storage/indexed-db.js984
-rw-r--r--devtools/server/actors/resources/storage/local-and-session-storage.js200
-rw-r--r--devtools/server/actors/resources/storage/moz.build17
-rw-r--r--devtools/server/actors/resources/stylesheets.js136
-rw-r--r--devtools/server/actors/resources/thread-states.js136
-rw-r--r--devtools/server/actors/resources/tracing-state.js63
-rw-r--r--devtools/server/actors/resources/utils/content-process-storage.js453
-rw-r--r--devtools/server/actors/resources/utils/moz.build14
-rw-r--r--devtools/server/actors/resources/utils/nsi-console-listener-watcher.js192
-rw-r--r--devtools/server/actors/resources/utils/parent-process-storage.js580
-rw-r--r--devtools/server/actors/resources/websockets.js196
38 files changed, 7635 insertions, 0 deletions
diff --git a/devtools/server/actors/resources/console-messages.js b/devtools/server/actors/resources/console-messages.js
new file mode 100644
index 0000000000..a643546692
--- /dev/null
+++ b/devtools/server/actors/resources/console-messages.js
@@ -0,0 +1,302 @@
+/* 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 {
+ TYPES: { CONSOLE_MESSAGE },
+} = require("devtools/server/actors/resources/index");
+const Targets = require("devtools/server/actors/targets/index");
+
+const consoleAPIListenerModule = isWorker
+ ? "devtools/server/actors/webconsole/worker-listeners"
+ : "devtools/server/actors/webconsole/listeners/console-api";
+const { ConsoleAPIListener } = require(consoleAPIListenerModule);
+
+const { isArray } = require("devtools/server/actors/object/utils");
+
+const {
+ makeDebuggeeValue,
+ createValueGripForTarget,
+} = require("devtools/server/actors/object/utils");
+
+const {
+ getActorIdForInternalSourceId,
+} = require("devtools/server/actors/utils/dbg-source");
+
+const {
+ isSupportedByConsoleTable,
+} = require("devtools/shared/webconsole/messages");
+
+/**
+ * Start watching for all console messages related to a given Target Actor.
+ * This will notify about existing console messages, but also the one created in future.
+ *
+ * @param TargetActor targetActor
+ * The target actor from which we should observe console messages
+ * @param Object options
+ * Dictionary object with following attributes:
+ * - onAvailable: mandatory function
+ * This will be called for each resource.
+ */
+class ConsoleMessageWatcher {
+ async watch(targetActor, { onAvailable }) {
+ this.targetActor = targetActor;
+ this.onAvailable = onAvailable;
+
+ // Bug 1642297: Maybe we could merge ConsoleAPI Listener into this module?
+ const onConsoleAPICall = message => {
+ onAvailable([
+ {
+ resourceType: CONSOLE_MESSAGE,
+ message: prepareConsoleMessageForRemote(targetActor, message),
+ },
+ ]);
+ };
+
+ const isTargetActorContentProcess =
+ targetActor.targetType === Targets.TYPES.PROCESS;
+
+ // Only consider messages from a given window for all FRAME targets (this includes
+ // WebExt and ParentProcess which inherits from WindowGlobalTargetActor)
+ // But ParentProcess should be ignored as we want all messages emitted directly from
+ // that process (window and window-less).
+ // To do that we pass a null window and ConsoleAPIListener will catch everything.
+ // And also ignore WebExtension as we will filter out only by addonId, which is
+ // passed via consoleAPIListenerOptions. WebExtension may have multiple windows/documents
+ // but all of them will be flagged with the same addon ID.
+ const messagesShouldMatchWindow =
+ targetActor.targetType === Targets.TYPES.FRAME &&
+ targetActor.typeName != "parentProcessTarget" &&
+ targetActor.typeName != "webExtensionTarget";
+ const window = messagesShouldMatchWindow ? targetActor.window : null;
+
+ // If we should match messages for a given window but for some reason, targetActor.window
+ // did not return a window, bail out. Otherwise we wouldn't have anything to match against
+ // and would consume all the messages, which could lead to issue (e.g. infinite loop,
+ // see Bug 1828026).
+ if (messagesShouldMatchWindow && !window) {
+ return;
+ }
+
+ const listener = new ConsoleAPIListener(window, onConsoleAPICall, {
+ excludeMessagesBoundToWindow: isTargetActorContentProcess,
+ matchExactWindow: targetActor.ignoreSubFrames,
+ ...(targetActor.consoleAPIListenerOptions || {}),
+ });
+ this.listener = listener;
+ listener.init();
+
+ // It can happen that the targetActor does not have a window reference (e.g. in worker
+ // thread, targetActor exposes a workerGlobal property)
+ const winStartTime =
+ targetActor.window?.performance?.timing?.navigationStart || 0;
+
+ const cachedMessages = listener.getCachedMessages(!targetActor.isRootActor);
+ const messages = [];
+ // Filter out messages that came from a ServiceWorker but happened
+ // before the page was requested.
+ for (const message of cachedMessages) {
+ if (
+ message.innerID === "ServiceWorker" &&
+ winStartTime > message.timeStamp
+ ) {
+ continue;
+ }
+ messages.push({
+ resourceType: CONSOLE_MESSAGE,
+ message: prepareConsoleMessageForRemote(targetActor, message),
+ });
+ }
+ onAvailable(messages);
+ }
+
+ /**
+ * Stop watching for console messages.
+ */
+ destroy() {
+ if (this.listener) {
+ this.listener.destroy();
+ this.listener = null;
+ }
+ this.targetActor = null;
+ this.onAvailable = null;
+ }
+
+ /**
+ * Spawn some custom console messages.
+ * This is used for example for log points and JS tracing.
+ *
+ * @param Array<Object> messages
+ * A list of fake nsIConsoleMessage, which looks like the one being generated by
+ * the platform API.
+ */
+ emitMessages(messages) {
+ if (!this.listener) {
+ throw new Error("This target actor isn't listening to console messages");
+ }
+ this.onAvailable(
+ messages.map(message => {
+ if (!message.timeStamp) {
+ throw new Error("timeStamp property is mandatory");
+ }
+
+ return {
+ resourceType: CONSOLE_MESSAGE,
+ message: prepareConsoleMessageForRemote(this.targetActor, message),
+ };
+ })
+ );
+ }
+}
+
+module.exports = ConsoleMessageWatcher;
+
+/**
+ * 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 {TargetActor} targetActor: The Target Actor from which this object originates.
+ * @param {Object} result: The console.table message.
+ * @returns {Object} An object containing the properties of the first argument of the
+ * console.table call.
+ */
+function getConsoleTableMessageItems(targetActor, result) {
+ const [tableItemGrip] = result.arguments;
+ const dataType = tableItemGrip.class;
+ const needEntries = ["Map", "WeakMap", "Set", "WeakSet"].includes(dataType);
+ const ignoreNonIndexedProperties = isArray(tableItemGrip);
+
+ const tableItemActor = targetActor.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 && targetActor.getActorByID(grip.actor);
+ if (actor) {
+ const res = actor
+ .enumProperties({
+ ignoreNonIndexedProperties: isArray(grip),
+ })
+ .all();
+ if (res?.ownProperties) {
+ desc[key].ownProperties = res.ownProperties;
+ }
+ }
+ }
+ });
+ }
+ });
+
+ return ownProperties;
+}
+
+/**
+ * Prepare a message from the console API to be sent to the remote Web Console
+ * instance.
+ *
+ * @param TargetActor targetActor
+ * The related target actor
+ * @param object message
+ * The original message received from the console storage listener.
+ * @return object
+ * The object that can be sent to the remote client.
+ */
+function prepareConsoleMessageForRemote(targetActor, message) {
+ const result = {
+ arguments: message.arguments
+ ? message.arguments.map(obj => {
+ const dbgObj = makeDebuggeeValue(targetActor, obj);
+ return createValueGripForTarget(targetActor, dbgObj);
+ })
+ : [],
+ 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 || ChromeUtils.dateNow(),
+ sourceId: getActorIdForInternalSourceId(targetActor, message.sourceId),
+ innerWindowID: message.innerID,
+ };
+
+ // This can be a hot path when loading lots of messages, and 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.chromeContext) {
+ result.chromeContext = message.chromeContext;
+ }
+
+ 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: getActorIdForInternalSourceId(targetActor, frame.sourceId),
+ };
+ });
+ }
+
+ if (message.styles && message.styles.length) {
+ result.styles = message.styles.map(string => {
+ return createValueGripForTarget(targetActor, string);
+ });
+ }
+
+ if (message.timer) {
+ result.timer = message.timer;
+ }
+
+ if (message.level === "table") {
+ if (result && isSupportedByConsoleTable(result.arguments)) {
+ const tableItems = getConsoleTableMessageItems(targetActor, 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);
+ }
+ }
+ // NOTE: See transformConsoleAPICallResource for not-supported case.
+ }
+
+ return result;
+}
diff --git a/devtools/server/actors/resources/css-changes.js b/devtools/server/actors/resources/css-changes.js
new file mode 100644
index 0000000000..e86503be87
--- /dev/null
+++ b/devtools/server/actors/resources/css-changes.js
@@ -0,0 +1,42 @@
+/* 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 {
+ TYPES: { CSS_CHANGE },
+} = require("resource://devtools/server/actors/resources/index.js");
+const TrackChangeEmitter = require("resource://devtools/server/actors/utils/track-change-emitter.js");
+
+/**
+ * Start watching for all css changes related to a given Target Actor.
+ *
+ * @param TargetActor targetActor
+ * The target actor from which we should observe css changes.
+ * @param Object options
+ * Dictionary object with following attributes:
+ * - onAvailable: mandatory function
+ * This will be called for each resource.
+ */
+class CSSChangeWatcher {
+ constructor() {
+ this.onTrackChange = this.onTrackChange.bind(this);
+ }
+
+ async watch(targetActor, { onAvailable }) {
+ this.onAvailable = onAvailable;
+ TrackChangeEmitter.on("track-change", this.onTrackChange);
+ }
+
+ onTrackChange(change) {
+ change.resourceType = CSS_CHANGE;
+ this.onAvailable([change]);
+ }
+
+ destroy() {
+ TrackChangeEmitter.off("track-change", this.onTrackChange);
+ }
+}
+
+module.exports = CSSChangeWatcher;
diff --git a/devtools/server/actors/resources/css-messages.js b/devtools/server/actors/resources/css-messages.js
new file mode 100644
index 0000000000..0bc6e7ac8a
--- /dev/null
+++ b/devtools/server/actors/resources/css-messages.js
@@ -0,0 +1,202 @@
+/* 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 nsIConsoleListenerWatcher = require("resource://devtools/server/actors/resources/utils/nsi-console-listener-watcher.js");
+const {
+ DevToolsServer,
+} = require("resource://devtools/server/devtools-server.js");
+const {
+ createStringGrip,
+} = require("resource://devtools/server/actors/object/utils.js");
+const {
+ getActorIdForInternalSourceId,
+} = require("resource://devtools/server/actors/utils/dbg-source.js");
+const {
+ WebConsoleUtils,
+} = require("resource://devtools/server/actors/webconsole/utils.js");
+
+loader.lazyRequireGetter(
+ this,
+ ["getStyleSheetText"],
+ "resource://devtools/server/actors/utils/stylesheet-utils.js",
+ true
+);
+
+const {
+ TYPES: { CSS_MESSAGE },
+} = require("resource://devtools/server/actors/resources/index.js");
+
+const { MESSAGE_CATEGORY } = require("resource://devtools/shared/constants.js");
+
+class CSSMessageWatcher extends nsIConsoleListenerWatcher {
+ /**
+ * Start watching for all CSS messages related to a given Target Actor.
+ * This will notify about existing messages, but also the one created in future.
+ *
+ * @param TargetActor targetActor
+ * The target actor from which we should observe messages
+ * @param Object options
+ * Dictionary object with following attributes:
+ * - onAvailable: mandatory function
+ * This will be called for each resource.
+ */
+ async watch(targetActor, { onAvailable }) {
+ super.watch(targetActor, { onAvailable });
+
+ // Calling ensureCSSErrorReportingEnabled will make the server parse the stylesheets to
+ // retrieve the warnings if the docShell wasn't already watching for CSS messages.
+ await this.#ensureCSSErrorReportingEnabled(targetActor);
+ }
+
+ /**
+ * Returns true if the message is considered a CSS message, and as a result, should
+ * be sent to the client.
+ *
+ * @param {nsIConsoleMessage|nsIScriptError} message
+ */
+ shouldHandleMessage(targetActor, message) {
+ // The listener we use can be called either with a nsIConsoleMessage or as nsIScriptError.
+ // In this file, we want to ignore anything but nsIScriptError.
+ if (
+ // We only care about CSS Parser nsIScriptError
+ !(message instanceof Ci.nsIScriptError) ||
+ message.category !== MESSAGE_CATEGORY.CSS_PARSER
+ ) {
+ return false;
+ }
+
+ // Filter specific to CONTENT PROCESS targets
+ // Process targets listen for everything but messages from private windows.
+ if (this.isProcessTarget(targetActor)) {
+ return !message.isFromPrivateWindow;
+ }
+
+ if (!message.innerWindowID) {
+ return false;
+ }
+
+ const ids = targetActor.windows.map(window =>
+ WebConsoleUtils.getInnerWindowId(window)
+ );
+ return ids.includes(message.innerWindowID);
+ }
+
+ /**
+ * Prepare an nsIScriptError to be sent to the client.
+ *
+ * @param nsIScriptError error
+ * The page error we need to send to the client.
+ * @return object
+ * The object you can send to the remote client.
+ */
+ buildResource(targetActor, error) {
+ const stack = this.prepareStackForRemote(targetActor, error.stack);
+ let lineText = error.sourceLine;
+ if (
+ lineText &&
+ lineText.length > DevToolsServer.LONG_STRING_INITIAL_LENGTH
+ ) {
+ lineText = lineText.substr(0, DevToolsServer.LONG_STRING_INITIAL_LENGTH);
+ }
+
+ const notesArray = this.prepareNotesForRemote(targetActor, error.notes);
+
+ // 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 } = error;
+ if (!sourceName && !sourceId && !lineNumber && !columnNumber && stack) {
+ sourceName = stack[0].filename;
+ sourceId = stack[0].sourceId;
+ lineNumber = stack[0].lineNumber;
+ columnNumber = stack[0].columnNumber;
+ }
+
+ const pageError = {
+ errorMessage: createStringGrip(targetActor, error.errorMessage),
+ sourceName,
+ sourceId: getActorIdForInternalSourceId(targetActor, sourceId),
+ lineText,
+ lineNumber,
+ columnNumber,
+ category: error.category,
+ innerWindowID: error.innerWindowID,
+ timeStamp: error.microSecondTimeStamp / 1000,
+ warning: !!(error.flags & error.warningFlag),
+ error: !(error.flags & (error.warningFlag | error.infoFlag)),
+ info: !!(error.flags & error.infoFlag),
+ private: error.isFromPrivateWindow,
+ stacktrace: stack,
+ notes: notesArray,
+ chromeContext: error.isFromChromeContext,
+ isForwardedFromContentProcess: error.isForwardedFromContentProcess,
+ };
+
+ return {
+ pageError,
+ resourceType: CSS_MESSAGE,
+ cssSelectors: error.cssSelectors,
+ };
+ }
+
+ /**
+ * Ensure that CSS error reporting is enabled for the provided target actor.
+ *
+ * @param {TargetActor} targetActor
+ * The target actor for which CSS Error Reporting should be enabled.
+ * @return {Promise} Promise that resolves when cssErrorReportingEnabled was
+ * set in all the docShells owned by the provided target, and existing
+ * stylesheets have been re-parsed if needed.
+ */
+ async #ensureCSSErrorReportingEnabled(targetActor) {
+ const docShells = targetActor.docShells;
+ if (!docShells) {
+ // If the target actor does not expose a docShells getter (ie is not an
+ // instance of WindowGlobalTargetActor), nothing to do here.
+ return;
+ }
+
+ const promises = docShells.map(async docShell => {
+ if (docShell.cssErrorReportingEnabled) {
+ // CSS Error Reporting already enabled here, nothing to do.
+ return;
+ }
+
+ try {
+ docShell.cssErrorReportingEnabled = true;
+ } catch (e) {
+ return;
+ }
+
+ // After enabling CSS Error Reporting, reparse existing stylesheets to
+ // detect potential CSS errors.
+
+ // Ensure docShell.document is available.
+ docShell.QueryInterface(Ci.nsIWebNavigation);
+ // We don't really want to reparse UA sheets and such, but want to do
+ // Shadow DOM / XBL.
+ const sheets = InspectorUtils.getAllStyleSheets(
+ docShell.document,
+ /* documentOnly = */ true
+ );
+ for (const sheet of sheets) {
+ if (InspectorUtils.hasRulesModifiedByCSSOM(sheet)) {
+ continue;
+ }
+
+ try {
+ // Reparse the sheet so that we see the existing errors.
+ const text = await getStyleSheetText(sheet);
+ InspectorUtils.parseStyleSheet(sheet, text, /* aUpdate = */ false);
+ } catch (e) {
+ console.error("Error while parsing stylesheet");
+ }
+ }
+ });
+
+ await Promise.all(promises);
+ }
+}
+module.exports = CSSMessageWatcher;
diff --git a/devtools/server/actors/resources/document-event.js b/devtools/server/actors/resources/document-event.js
new file mode 100644
index 0000000000..bd6667b2b5
--- /dev/null
+++ b/devtools/server/actors/resources/document-event.js
@@ -0,0 +1,112 @@
+/* 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 {
+ TYPES: { DOCUMENT_EVENT },
+} = require("resource://devtools/server/actors/resources/index.js");
+const {
+ DocumentEventsListener,
+} = require("resource://devtools/server/actors/webconsole/listeners/document-events.js");
+
+class DocumentEventWatcher {
+ #abortController = new AbortController();
+ /**
+ * Start watching for all document event related to a given Target Actor.
+ *
+ * @param TargetActor targetActor
+ * The target actor from which we should observe document event
+ * @param Object options
+ * Dictionary object with following attributes:
+ * - onAvailable: mandatory function
+ * This will be called for each resource.
+ */
+ async watch(targetActor, { onAvailable }) {
+ if (isWorker) {
+ return;
+ }
+
+ const onDocumentEvent = (
+ name,
+ {
+ time,
+ // This will be `true` when the user selected a document in the frame picker tool,
+ // in the toolbox toolbar.
+ isFrameSwitching,
+ // This is only passed for dom-complete event
+ hasNativeConsoleAPI,
+ // This is only passed for will-navigate event
+ newURI,
+ } = {}
+ ) => {
+ // Ignore will-navigate as that's managed by parent-process-document-event.js.
+ // Except frame switching, when selecting an iframe document via the dropdown menu,
+ // this is handled by the target actor in the content process and the parent process
+ // doesn't know about it.
+ if (name == "will-navigate" && !isFrameSwitching) {
+ return;
+ }
+ onAvailable([
+ {
+ resourceType: DOCUMENT_EVENT,
+ name,
+ time,
+ isFrameSwitching,
+ // only send `title` on dom interactive (once the HTML was parsed) so we don't
+ // make the payload bigger for events where we either don't have a title yet,
+ // or where we already had a chance to get the title.
+ title: name === "dom-interactive" ? targetActor.title : undefined,
+ // only send `url` on dom loading and dom-interactive so we don't make the
+ // payload bigger for other events
+ url:
+ name === "dom-loading" || name === "dom-interactive"
+ ? targetActor.url
+ : undefined,
+ // only send `newURI` on will navigate so we don't make the payload bigger for
+ // other events
+ newURI: name === "will-navigate" ? newURI : null,
+ // only send `hasNativeConsoleAPI` on dom complete so we don't make the payload bigger for
+ // other events
+ hasNativeConsoleAPI:
+ name == "dom-complete" ? hasNativeConsoleAPI : null,
+ },
+ ]);
+ };
+
+ this.listener = new DocumentEventsListener(targetActor);
+
+ this.listener.on(
+ "will-navigate",
+ data => onDocumentEvent("will-navigate", data),
+ { signal: this.#abortController.signal }
+ );
+ this.listener.on(
+ "dom-loading",
+ data => onDocumentEvent("dom-loading", data),
+ { signal: this.#abortController.signal }
+ );
+ this.listener.on(
+ "dom-interactive",
+ data => onDocumentEvent("dom-interactive", data),
+ { signal: this.#abortController.signal }
+ );
+ this.listener.on(
+ "dom-complete",
+ data => onDocumentEvent("dom-complete", data),
+ { signal: this.#abortController.signal }
+ );
+
+ this.listener.listen();
+ }
+
+ destroy() {
+ this.#abortController.abort();
+ if (this.listener) {
+ this.listener.destroy();
+ }
+ }
+}
+
+module.exports = DocumentEventWatcher;
diff --git a/devtools/server/actors/resources/error-messages.js b/devtools/server/actors/resources/error-messages.js
new file mode 100644
index 0000000000..7628d7fd6d
--- /dev/null
+++ b/devtools/server/actors/resources/error-messages.js
@@ -0,0 +1,192 @@
+/* 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 nsIConsoleListenerWatcher = require("resource://devtools/server/actors/resources/utils/nsi-console-listener-watcher.js");
+const {
+ DevToolsServer,
+} = require("resource://devtools/server/devtools-server.js");
+const ErrorDocs = require("resource://devtools/server/actors/errordocs.js");
+const {
+ createStringGrip,
+ makeDebuggeeValue,
+ createValueGripForTarget,
+} = require("resource://devtools/server/actors/object/utils.js");
+const {
+ getActorIdForInternalSourceId,
+} = require("resource://devtools/server/actors/utils/dbg-source.js");
+const {
+ WebConsoleUtils,
+} = require("resource://devtools/server/actors/webconsole/utils.js");
+
+const {
+ TYPES: { ERROR_MESSAGE },
+} = require("resource://devtools/server/actors/resources/index.js");
+const Targets = require("resource://devtools/server/actors/targets/index.js");
+
+const { MESSAGE_CATEGORY } = require("resource://devtools/shared/constants.js");
+
+const PLATFORM_SPECIFIC_CATEGORIES = [
+ "XPConnect JavaScript",
+ "component javascript",
+ "chrome javascript",
+ "chrome registration",
+];
+
+class ErrorMessageWatcher extends nsIConsoleListenerWatcher {
+ shouldHandleMessage(targetActor, message, isCachedMessage = false) {
+ // The listener we use can be called either with a nsIConsoleMessage or a nsIScriptError.
+ // In this file, we only want to handle nsIScriptError.
+ if (
+ // We only care about nsIScriptError
+ !(message instanceof Ci.nsIScriptError) ||
+ !this.isCategoryAllowed(targetActor, message.category) ||
+ // Block any error that was triggered by eager evaluation
+ message.sourceName === "debugger eager eval code"
+ ) {
+ return false;
+ }
+
+ // Filter specific to CONTENT PROCESS targets
+ if (this.isProcessTarget(targetActor)) {
+ // Don't want to display cached messages from private windows.
+ const isCachedFromPrivateWindow =
+ isCachedMessage && message.isFromPrivateWindow;
+ if (isCachedFromPrivateWindow) {
+ return false;
+ }
+
+ // `ContentChild` forwards all errors to the parent process (via IPC) all errors up
+ // the parent process and sets a `isForwardedFromContentProcess` property on them.
+ // Ignore these forwarded messages as the original ones will be logged either in a
+ // content process target (if window-less message) or frame target (if related to a window)
+ if (message.isForwardedFromContentProcess) {
+ return false;
+ }
+
+ // Ignore all messages related to a given window for content process targets
+ // These messages will be handled by Watchers instantiated for the related frame targets
+ if (
+ targetActor.targetType == Targets.TYPES.PROCESS &&
+ message.innerWindowID
+ ) {
+ return false;
+ }
+
+ return true;
+ }
+
+ if (!message.innerWindowID) {
+ return false;
+ }
+
+ const ids = targetActor.windows.map(window =>
+ WebConsoleUtils.getInnerWindowId(window)
+ );
+ return ids.includes(message.innerWindowID);
+ }
+
+ /**
+ * 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(targetActor, category) {
+ // CSS Parser errors will be handled by the CSSMessageWatcher.
+ if (!category || category === MESSAGE_CATEGORY.CSS_PARSER) {
+ return false;
+ }
+
+ // We listen for everything on Process targets
+ if (this.isProcessTarget(targetActor)) {
+ return true;
+ }
+
+ // Don't restrict any categories in the Browser Toolbox/Browser Console
+ if (targetActor.sessionContext.type == "all") {
+ return true;
+ }
+
+ // For non-process targets in other toolboxes, we filter-out platform-specific errors.
+ return !PLATFORM_SPECIFIC_CATEGORIES.includes(category);
+ }
+
+ /**
+ * Prepare an nsIScriptError to be sent to the client.
+ *
+ * @param nsIScriptError error
+ * The page error we need to send to the client.
+ * @return object
+ * The object you can send to the remote client.
+ */
+ buildResource(targetActor, error) {
+ const stack = this.prepareStackForRemote(targetActor, error.stack);
+ let lineText = error.sourceLine;
+ if (
+ lineText &&
+ lineText.length > DevToolsServer.LONG_STRING_INITIAL_LENGTH
+ ) {
+ lineText = lineText.substr(0, DevToolsServer.LONG_STRING_INITIAL_LENGTH);
+ }
+
+ const notesArray = this.prepareNotesForRemote(targetActor, error.notes);
+
+ // 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 } = error;
+ if (!sourceName && !sourceId && !lineNumber && !columnNumber && stack) {
+ sourceName = stack[0].filename;
+ sourceId = stack[0].sourceId;
+ lineNumber = stack[0].lineNumber;
+ columnNumber = stack[0].columnNumber;
+ }
+
+ const pageError = {
+ errorMessage: createStringGrip(targetActor, error.errorMessage),
+ errorMessageName: error.errorMessageName,
+ exceptionDocURL: ErrorDocs.GetURL(error),
+ sourceName,
+ sourceId: getActorIdForInternalSourceId(targetActor, sourceId),
+ lineText,
+ lineNumber,
+ columnNumber,
+ category: error.category,
+ innerWindowID: error.innerWindowID,
+ timeStamp: error.microSecondTimeStamp / 1000,
+ warning: !!(error.flags & error.warningFlag),
+ error: !(error.flags & (error.warningFlag | error.infoFlag)),
+ info: !!(error.flags & error.infoFlag),
+ private: error.isFromPrivateWindow,
+ stacktrace: stack,
+ notes: notesArray,
+ chromeContext: error.isFromChromeContext,
+ isPromiseRejection: error.isPromiseRejection,
+ isForwardedFromContentProcess: error.isForwardedFromContentProcess,
+ };
+
+ // 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 (error.hasException) {
+ try {
+ const obj = makeDebuggeeValue(targetActor, error.exception);
+ if (obj?.class !== "DeadObject") {
+ pageError.exception = createValueGripForTarget(targetActor, obj);
+ pageError.hasException = true;
+ }
+ } catch (e) {}
+ }
+
+ return {
+ pageError,
+ resourceType: ERROR_MESSAGE,
+ };
+ }
+}
+module.exports = ErrorMessageWatcher;
diff --git a/devtools/server/actors/resources/extensions-backgroundscript-status.js b/devtools/server/actors/resources/extensions-backgroundscript-status.js
new file mode 100644
index 0000000000..08f51a23f5
--- /dev/null
+++ b/devtools/server/actors/resources/extensions-backgroundscript-status.js
@@ -0,0 +1,68 @@
+/* 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 {
+ TYPES: { EXTENSIONS_BGSCRIPT_STATUS },
+} = require("resource://devtools/server/actors/resources/index.js");
+
+class ExtensionsBackgroundScriptStatusWatcher {
+ /**
+ * Start watching for the status updates related to a background
+ * scripts extension context (either an event page or a background
+ * service worker).
+ *
+ * This is used in about:debugging to update the background script
+ * row updated visible in Extensions details cards (only for extensions
+ * with a non persistent background script defined in the manifest)
+ * when the background contex is terminated on idle or started back
+ * to handle a persistent WebExtensions API event.
+ *
+ * @param RootActor rootActor
+ * The root actor in the parent process from which we should
+ * observe root resources.
+ * @param Object options
+ * Dictionary object with following attributes:
+ * - onAvailable: mandatory function
+ * This will be called for each resource.
+ */
+ async watch(rootActor, { onAvailable }) {
+ this.rootActor = rootActor;
+ this.onAvailable = onAvailable;
+
+ Services.obs.addObserver(this, "extension:background-script-status");
+ }
+
+ observe(subject, topic, data) {
+ switch (topic) {
+ case "extension:background-script-status": {
+ const { addonId, isRunning } = subject.wrappedJSObject;
+ this.onBackgroundScriptStatus(addonId, isRunning);
+ break;
+ }
+ }
+ }
+
+ onBackgroundScriptStatus(addonId, isRunning) {
+ this.onAvailable([
+ {
+ resourceType: EXTENSIONS_BGSCRIPT_STATUS,
+ payload: {
+ addonId,
+ isRunning,
+ },
+ },
+ ]);
+ }
+
+ destroy() {
+ if (this.onAvailable) {
+ this.onAvailable = null;
+ Services.obs.removeObserver(this, "extension:background-script-status");
+ }
+ }
+}
+
+module.exports = ExtensionsBackgroundScriptStatusWatcher;
diff --git a/devtools/server/actors/resources/index.js b/devtools/server/actors/resources/index.js
new file mode 100644
index 0000000000..a0208ba04f
--- /dev/null
+++ b/devtools/server/actors/resources/index.js
@@ -0,0 +1,451 @@
+/* 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 Targets = require("resource://devtools/server/actors/targets/index.js");
+
+const TYPES = {
+ CONSOLE_MESSAGE: "console-message",
+ CSS_CHANGE: "css-change",
+ CSS_MESSAGE: "css-message",
+ DOCUMENT_EVENT: "document-event",
+ ERROR_MESSAGE: "error-message",
+ LAST_PRIVATE_CONTEXT_EXIT: "last-private-context-exit",
+ NETWORK_EVENT: "network-event",
+ NETWORK_EVENT_STACKTRACE: "network-event-stacktrace",
+ PLATFORM_MESSAGE: "platform-message",
+ REFLOW: "reflow",
+ SERVER_SENT_EVENT: "server-sent-event",
+ SOURCE: "source",
+ STYLESHEET: "stylesheet",
+ THREAD_STATE: "thread-state",
+ TRACING_STATE: "tracing-state",
+ WEBSOCKET: "websocket",
+
+ // storage types
+ CACHE_STORAGE: "Cache",
+ COOKIE: "cookies",
+ EXTENSION_STORAGE: "extension-storage",
+ INDEXED_DB: "indexed-db",
+ LOCAL_STORAGE: "local-storage",
+ SESSION_STORAGE: "session-storage",
+
+ // root types
+ EXTENSIONS_BGSCRIPT_STATUS: "extensions-backgroundscript-status",
+};
+exports.TYPES = TYPES;
+
+// Helper dictionaries, which will contain data specific to each resource type.
+// - `path` is the absolute path to the module defining the Resource Watcher class.
+//
+// Also see the attributes added by `augmentResourceDictionary` for each type:
+// - `watchers` is a weak map which will store Resource Watchers
+// (i.e. devtools/server/actors/resources/ class instances)
+// keyed by target actor -or- watcher actor.
+// - `WatcherClass` is a shortcut to the Resource Watcher module.
+// Each module exports a Resource Watcher class.
+//
+// These are several dictionaries, which depend how the resource watcher classes are instantiated.
+
+// Frame target resources are spawned via a BrowsingContext Target Actor.
+// Their watcher class receives a target actor as first argument.
+// They are instantiated for each observed BrowsingContext, from the content process where it runs.
+// They are meant to observe all resources related to a given Browsing Context.
+const FrameTargetResources = augmentResourceDictionary({
+ [TYPES.CACHE_STORAGE]: {
+ path: "devtools/server/actors/resources/storage-cache",
+ },
+ [TYPES.CONSOLE_MESSAGE]: {
+ path: "devtools/server/actors/resources/console-messages",
+ },
+ [TYPES.CSS_CHANGE]: {
+ path: "devtools/server/actors/resources/css-changes",
+ },
+ [TYPES.CSS_MESSAGE]: {
+ path: "devtools/server/actors/resources/css-messages",
+ },
+ [TYPES.DOCUMENT_EVENT]: {
+ path: "devtools/server/actors/resources/document-event",
+ },
+ [TYPES.ERROR_MESSAGE]: {
+ path: "devtools/server/actors/resources/error-messages",
+ },
+ [TYPES.LOCAL_STORAGE]: {
+ path: "devtools/server/actors/resources/storage-local-storage",
+ },
+ [TYPES.PLATFORM_MESSAGE]: {
+ path: "devtools/server/actors/resources/platform-messages",
+ },
+ [TYPES.SESSION_STORAGE]: {
+ path: "devtools/server/actors/resources/storage-session-storage",
+ },
+ [TYPES.STYLESHEET]: {
+ path: "devtools/server/actors/resources/stylesheets",
+ },
+ [TYPES.NETWORK_EVENT]: {
+ path: "devtools/server/actors/resources/network-events-content",
+ },
+ [TYPES.NETWORK_EVENT_STACKTRACE]: {
+ path: "devtools/server/actors/resources/network-events-stacktraces",
+ },
+ [TYPES.REFLOW]: {
+ path: "devtools/server/actors/resources/reflow",
+ },
+ [TYPES.SOURCE]: {
+ path: "devtools/server/actors/resources/sources",
+ },
+ [TYPES.THREAD_STATE]: {
+ path: "devtools/server/actors/resources/thread-states",
+ },
+ [TYPES.TRACING_STATE]: {
+ path: "devtools/server/actors/resources/tracing-state",
+ },
+ [TYPES.SERVER_SENT_EVENT]: {
+ path: "devtools/server/actors/resources/server-sent-events",
+ },
+ [TYPES.WEBSOCKET]: {
+ path: "devtools/server/actors/resources/websockets",
+ },
+});
+
+// Process target resources are spawned via a Process Target Actor.
+// Their watcher class receives a process target actor as first argument.
+// They are instantiated for each observed Process (parent and all content processes).
+// They are meant to observe all resources related to a given process.
+const ProcessTargetResources = augmentResourceDictionary({
+ [TYPES.CONSOLE_MESSAGE]: {
+ path: "devtools/server/actors/resources/console-messages",
+ },
+ [TYPES.ERROR_MESSAGE]: {
+ path: "devtools/server/actors/resources/error-messages",
+ },
+ [TYPES.PLATFORM_MESSAGE]: {
+ path: "devtools/server/actors/resources/platform-messages",
+ },
+ [TYPES.SOURCE]: {
+ path: "devtools/server/actors/resources/sources",
+ },
+ [TYPES.THREAD_STATE]: {
+ path: "devtools/server/actors/resources/thread-states",
+ },
+ [TYPES.TRACING_STATE]: {
+ path: "devtools/server/actors/resources/tracing-state",
+ },
+});
+
+// Worker target resources are spawned via a Worker Target Actor.
+// Their watcher class receives a worker target actor as first argument.
+// They are instantiated for each observed worker, from the worker thread.
+// They are meant to observe all resources related to a given worker.
+//
+// We'll only support a few resource types in Workers (console-message, source,
+// thread state, …) as error and platform messages are not supported since we need access
+// to Ci, which isn't available in worker context.
+// Errors are emitted from the content process main thread so the user would still get them.
+const WorkerTargetResources = augmentResourceDictionary({
+ [TYPES.CONSOLE_MESSAGE]: {
+ path: "devtools/server/actors/resources/console-messages",
+ },
+ [TYPES.SOURCE]: {
+ path: "devtools/server/actors/resources/sources",
+ },
+ [TYPES.THREAD_STATE]: {
+ path: "devtools/server/actors/resources/thread-states",
+ },
+ [TYPES.TRACING_STATE]: {
+ path: "devtools/server/actors/resources/tracing-state",
+ },
+});
+
+// Parent process resources are spawned via the Watcher Actor.
+// Their watcher class receives the watcher actor as first argument.
+// They are instantiated once per watcher from the parent process.
+// They are meant to observe all resources related to a given context designated by the Watcher (and its sessionContext)
+// they should be observed from the parent process.
+const ParentProcessResources = augmentResourceDictionary({
+ [TYPES.NETWORK_EVENT]: {
+ path: "devtools/server/actors/resources/network-events",
+ },
+ [TYPES.COOKIE]: {
+ path: "devtools/server/actors/resources/storage-cookie",
+ },
+ [TYPES.EXTENSION_STORAGE]: {
+ path: "devtools/server/actors/resources/storage-extension",
+ },
+ [TYPES.INDEXED_DB]: {
+ path: "devtools/server/actors/resources/storage-indexed-db",
+ },
+ [TYPES.DOCUMENT_EVENT]: {
+ path: "devtools/server/actors/resources/parent-process-document-event",
+ },
+ [TYPES.LAST_PRIVATE_CONTEXT_EXIT]: {
+ path: "devtools/server/actors/resources/last-private-context-exit",
+ },
+});
+
+// Root resources are spawned via the Root Actor.
+// Their watcher class receives the root actor as first argument.
+// They are instantiated only once from the parent process.
+// They are meant to observe anything easily observable from the parent process
+// that isn't related to any particular context/target.
+// This is especially useful when you need to observe something without having to instantiate a Watcher actor.
+const RootResources = augmentResourceDictionary({
+ [TYPES.EXTENSIONS_BGSCRIPT_STATUS]: {
+ path: "devtools/server/actors/resources/extensions-backgroundscript-status",
+ },
+});
+exports.RootResources = RootResources;
+
+function augmentResourceDictionary(dict) {
+ for (const resource of Object.values(dict)) {
+ resource.watchers = new WeakMap();
+
+ loader.lazyRequireGetter(resource, "WatcherClass", resource.path);
+ }
+ return dict;
+}
+
+/**
+ * For a given actor, return the related dictionary defined just before,
+ * that contains info about how to listen for a given resource type, from a given actor.
+ *
+ * @param Actor rootOrWatcherOrTargetActor
+ * Either a RootActor or WatcherActor or a TargetActor which can be listening to a resource.
+ */
+function getResourceTypeDictionary(rootOrWatcherOrTargetActor) {
+ const { typeName } = rootOrWatcherOrTargetActor;
+ if (typeName == "root") {
+ return RootResources;
+ }
+ if (typeName == "watcher") {
+ return ParentProcessResources;
+ }
+ const { targetType } = rootOrWatcherOrTargetActor;
+ return getResourceTypeDictionaryForTargetType(targetType);
+}
+
+/**
+ * For a targetType, return the related dictionary.
+ *
+ * @param String targetType
+ * A targetType string (See Targets.TYPES)
+ */
+function getResourceTypeDictionaryForTargetType(targetType) {
+ switch (targetType) {
+ case Targets.TYPES.FRAME:
+ return FrameTargetResources;
+ case Targets.TYPES.PROCESS:
+ return ProcessTargetResources;
+ case Targets.TYPES.WORKER:
+ return WorkerTargetResources;
+ default:
+ throw new Error(`Unsupported target actor typeName '${targetType}'`);
+ }
+}
+
+/**
+ * For a given actor, return the object stored in one of the previous dictionary
+ * that contains info about how to listen for a given resource type, from a given actor.
+ *
+ * @param Actor rootOrWatcherOrTargetActor
+ * Either a RootActor or WatcherActor or a TargetActor which can be listening to a resource.
+ * @param String resourceType
+ * The resource type to be observed.
+ */
+function getResourceTypeEntry(rootOrWatcherOrTargetActor, resourceType) {
+ const dict = getResourceTypeDictionary(rootOrWatcherOrTargetActor);
+ if (!(resourceType in dict)) {
+ throw new Error(
+ `Unsupported resource type '${resourceType}' for ${rootOrWatcherOrTargetActor.typeName}`
+ );
+ }
+ return dict[resourceType];
+}
+
+/**
+ * Start watching for a new list of resource types.
+ * This will also emit all already existing resources before resolving.
+ *
+ * @param Actor rootOrWatcherOrTargetActor
+ * Either a RootActor or WatcherActor or a TargetActor which can be listening to a resource:
+ * * RootActor will be used for resources observed from the parent process and aren't related to any particular
+ * context/descriptor. They can be observed right away when connecting to the RDP server
+ * without instantiating any actor other than the root actor.
+ * * WatcherActor will be used for resources listened from the parent process.
+ * * TargetActor will be used for resources listened from the content process.
+ * This actor:
+ * - defines what context to observe (browsing context, process, worker, ...)
+ * Via browsingContextID, windows, docShells attributes for the target actor.
+ * Via the `sessionContext` object for the watcher actor.
+ * (only for Watcher and Target actors. Root actor is context-less.)
+ * - exposes `notifyResources` method to be notified about all the resources updates
+ * This method will receive two arguments:
+ * - {String} updateType, which can be "available", "updated", or "destroyed"
+ * - {Array<Object>} resources, which will be the list of resource's forms
+ * or special update object for "updated" scenario.
+ * @param Array<String> resourceTypes
+ * List of all type of resource to listen to.
+ */
+async function watchResources(rootOrWatcherOrTargetActor, resourceTypes) {
+ // If we are given a target actor, filter out the resource types supported by the target.
+ // When using sharedData to pass types between processes, we are passing them for all target types.
+ const { targetType } = rootOrWatcherOrTargetActor;
+ // Only target actors usecase will have a target type.
+ // For Root and Watcher we process the `resourceTypes` list unfiltered.
+ if (targetType) {
+ resourceTypes = getResourceTypesForTargetType(resourceTypes, targetType);
+ }
+ for (const resourceType of resourceTypes) {
+ const { watchers, WatcherClass } = getResourceTypeEntry(
+ rootOrWatcherOrTargetActor,
+ resourceType
+ );
+
+ // Ignore resources we're already listening to
+ if (watchers.has(rootOrWatcherOrTargetActor)) {
+ continue;
+ }
+
+ // Don't watch for console messages from the worker target if worker messages are still
+ // being cloned to the main process, otherwise we'll get duplicated messages in the
+ // console output (See Bug 1778852).
+ if (
+ resourceType == TYPES.CONSOLE_MESSAGE &&
+ rootOrWatcherOrTargetActor.workerConsoleApiMessagesDispatchedToMainThread
+ ) {
+ continue;
+ }
+
+ const watcher = new WatcherClass();
+ await watcher.watch(rootOrWatcherOrTargetActor, {
+ onAvailable: rootOrWatcherOrTargetActor.notifyResources.bind(
+ rootOrWatcherOrTargetActor,
+ "available"
+ ),
+ onUpdated: rootOrWatcherOrTargetActor.notifyResources.bind(
+ rootOrWatcherOrTargetActor,
+ "updated"
+ ),
+ onDestroyed: rootOrWatcherOrTargetActor.notifyResources.bind(
+ rootOrWatcherOrTargetActor,
+ "destroyed"
+ ),
+ });
+ watchers.set(rootOrWatcherOrTargetActor, watcher);
+ }
+}
+exports.watchResources = watchResources;
+
+function getParentProcessResourceTypes(resourceTypes) {
+ return resourceTypes.filter(resourceType => {
+ return resourceType in ParentProcessResources;
+ });
+}
+exports.getParentProcessResourceTypes = getParentProcessResourceTypes;
+
+function getResourceTypesForTargetType(resourceTypes, targetType) {
+ const resourceDictionnary =
+ getResourceTypeDictionaryForTargetType(targetType);
+ return resourceTypes.filter(resourceType => {
+ return resourceType in resourceDictionnary;
+ });
+}
+exports.getResourceTypesForTargetType = getResourceTypesForTargetType;
+
+function hasResourceTypesForTargets(resourceTypes) {
+ return resourceTypes.some(resourceType => {
+ return resourceType in FrameTargetResources;
+ });
+}
+exports.hasResourceTypesForTargets = hasResourceTypesForTargets;
+
+/**
+ * Stop watching for a list of resource types.
+ *
+ * @param Actor rootOrWatcherOrTargetActor
+ * The related actor, already passed to watchResources.
+ * @param Array<String> resourceTypes
+ * List of all type of resource to stop listening to.
+ */
+function unwatchResources(rootOrWatcherOrTargetActor, resourceTypes) {
+ for (const resourceType of resourceTypes) {
+ // Pull all info about this resource type from `Resources` global object
+ const { watchers } = getResourceTypeEntry(
+ rootOrWatcherOrTargetActor,
+ resourceType
+ );
+
+ const watcher = watchers.get(rootOrWatcherOrTargetActor);
+ if (watcher) {
+ watcher.destroy();
+ watchers.delete(rootOrWatcherOrTargetActor);
+ }
+ }
+}
+exports.unwatchResources = unwatchResources;
+
+/**
+ * Clear resources for a list of resource types.
+ *
+ * @param Actor rootOrWatcherOrTargetActor
+ * The related actor, already passed to watchResources.
+ * @param Array<String> resourceTypes
+ * List of all type of resource to clear.
+ */
+function clearResources(rootOrWatcherOrTargetActor, resourceTypes) {
+ for (const resourceType of resourceTypes) {
+ const { watchers } = getResourceTypeEntry(
+ rootOrWatcherOrTargetActor,
+ resourceType
+ );
+
+ const watcher = watchers.get(rootOrWatcherOrTargetActor);
+ if (watcher && typeof watcher.clear == "function") {
+ watcher.clear();
+ }
+ }
+}
+
+exports.clearResources = clearResources;
+
+/**
+ * Stop watching for all watched resources on a given actor.
+ *
+ * @param Actor rootOrWatcherOrTargetActor
+ * The related actor, already passed to watchResources.
+ */
+function unwatchAllResources(rootOrWatcherOrTargetActor) {
+ for (const { watchers } of Object.values(
+ getResourceTypeDictionary(rootOrWatcherOrTargetActor)
+ )) {
+ const watcher = watchers.get(rootOrWatcherOrTargetActor);
+ if (watcher) {
+ watcher.destroy();
+ watchers.delete(rootOrWatcherOrTargetActor);
+ }
+ }
+}
+exports.unwatchAllResources = unwatchAllResources;
+
+/**
+ * If we are watching for the given resource type,
+ * return the current ResourceWatcher instance used by this target actor
+ * in order to observe this resource type.
+ *
+ * @param Actor watcherOrTargetActor
+ * Either a WatcherActor or a TargetActor which can be listening to a resource.
+ * WatcherActor will be used for resources listened from the parent process,
+ * and TargetActor will be used for resources listened from the content process.
+ * @param String resourceType
+ * The resource type to query
+ * @return ResourceWatcher
+ * The resource watcher instance, defined in devtools/server/actors/resources/
+ */
+function getResourceWatcher(watcherOrTargetActor, resourceType) {
+ const { watchers } = getResourceTypeEntry(watcherOrTargetActor, resourceType);
+
+ return watchers.get(watcherOrTargetActor);
+}
+exports.getResourceWatcher = getResourceWatcher;
diff --git a/devtools/server/actors/resources/last-private-context-exit.js b/devtools/server/actors/resources/last-private-context-exit.js
new file mode 100644
index 0000000000..ec9ee6b91d
--- /dev/null
+++ b/devtools/server/actors/resources/last-private-context-exit.js
@@ -0,0 +1,46 @@
+/* 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 {
+ TYPES: { LAST_PRIVATE_CONTEXT_EXIT },
+} = require("resource://devtools/server/actors/resources/index.js");
+
+class LastPrivateContextExitWatcher {
+ #onAvailable;
+
+ /**
+ * Start watching for all times where we close a private browsing top level window.
+ * Meaning we should clear the console for all logs generated from these private browsing contexts.
+ *
+ * @param WatcherActor watcherActor
+ * The watcher actor in the parent process from which we should
+ * observe these events.
+ * @param Object options
+ * Dictionary object with following attributes:
+ * - onAvailable: mandatory function
+ * This will be called for each resource.
+ */
+ async watch(watcherActor, { onAvailable }) {
+ this.#onAvailable = onAvailable;
+ Services.obs.addObserver(this, "last-pb-context-exited");
+ }
+
+ observe(subject, topic) {
+ if (topic === "last-pb-context-exited") {
+ this.#onAvailable([
+ {
+ resourceType: LAST_PRIVATE_CONTEXT_EXIT,
+ },
+ ]);
+ }
+ }
+
+ destroy() {
+ Services.obs.removeObserver(this, "last-pb-context-exited");
+ }
+}
+
+module.exports = LastPrivateContextExitWatcher;
diff --git a/devtools/server/actors/resources/moz.build b/devtools/server/actors/resources/moz.build
new file mode 100644
index 0000000000..3768750949
--- /dev/null
+++ b/devtools/server/actors/resources/moz.build
@@ -0,0 +1,42 @@
+# -*- 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 += [
+ "storage",
+ "utils",
+]
+
+DevToolsModules(
+ "console-messages.js",
+ "css-changes.js",
+ "css-messages.js",
+ "document-event.js",
+ "error-messages.js",
+ "extensions-backgroundscript-status.js",
+ "index.js",
+ "last-private-context-exit.js",
+ "network-events-content.js",
+ "network-events-stacktraces.js",
+ "network-events.js",
+ "parent-process-document-event.js",
+ "platform-messages.js",
+ "reflow.js",
+ "server-sent-events.js",
+ "sources.js",
+ "storage-cache.js",
+ "storage-cookie.js",
+ "storage-extension.js",
+ "storage-indexed-db.js",
+ "storage-local-storage.js",
+ "storage-session-storage.js",
+ "stylesheets.js",
+ "thread-states.js",
+ "tracing-state.js",
+ "websockets.js",
+)
+
+with Files("*-messages.js"):
+ BUG_COMPONENT = ("DevTools", "Console")
diff --git a/devtools/server/actors/resources/network-events-content.js b/devtools/server/actors/resources/network-events-content.js
new file mode 100644
index 0000000000..022a145db5
--- /dev/null
+++ b/devtools/server/actors/resources/network-events-content.js
@@ -0,0 +1,266 @@
+/* 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,
+ "NetworkEventActor",
+ "resource://devtools/server/actors/network-monitor/network-event-actor.js",
+ true
+);
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ NetworkUtils:
+ "resource://devtools/shared/network-observer/NetworkUtils.sys.mjs",
+});
+
+/**
+ * Handles network events from the content process
+ * This currently only handles events for requests (js/css) blocked by CSP.
+ */
+class NetworkEventContentWatcher {
+ /**
+ * Start watching for all network events related to a given Target Actor.
+ *
+ * @param TargetActor targetActor
+ * The target actor in the content process from which we should
+ * observe network events.
+ * @param Object options
+ * Dictionary object with following attributes:
+ * - onAvailable: mandatory function
+ * This will be called for each resource.
+ * - onUpdated: optional function
+ * This would be called multiple times for each resource.
+ */
+ async watch(targetActor, { onAvailable, onUpdated }) {
+ // Map from channelId to network event objects.
+ this.networkEvents = new Map();
+
+ this.targetActor = targetActor;
+ this.onAvailable = onAvailable;
+ this.onUpdated = onUpdated;
+
+ this.httpFailedOpeningRequest = this.httpFailedOpeningRequest.bind(this);
+ this.httpOnImageCacheResponse = this.httpOnImageCacheResponse.bind(this);
+
+ Services.obs.addObserver(
+ this.httpFailedOpeningRequest,
+ "http-on-failed-opening-request"
+ );
+
+ Services.obs.addObserver(
+ this.httpOnImageCacheResponse,
+ "http-on-image-cache-response"
+ );
+ }
+ /**
+ * Allows clearing of network events
+ */
+ clear() {
+ this.networkEvents.clear();
+ }
+
+ httpFailedOpeningRequest(subject, topic) {
+ const channel = subject.QueryInterface(Ci.nsIHttpChannel);
+
+ // Ignore preload requests to avoid duplicity request entries in
+ // the Network panel. If a preload fails (for whatever reason)
+ // then the platform kicks off another 'real' request.
+ if (lazy.NetworkUtils.isPreloadRequest(channel)) {
+ return;
+ }
+
+ if (
+ !lazy.NetworkUtils.matchRequest(channel, {
+ targetActor: this.targetActor,
+ })
+ ) {
+ return;
+ }
+
+ this.onNetworkEventAvailable(channel, {
+ networkEventOptions: {
+ blockedReason: channel.loadInfo.requestBlockingReason,
+ },
+ });
+ }
+
+ httpOnImageCacheResponse(subject, topic) {
+ if (
+ topic != "http-on-image-cache-response" ||
+ !(subject instanceof Ci.nsIHttpChannel)
+ ) {
+ return;
+ }
+
+ const channel = subject.QueryInterface(Ci.nsIHttpChannel);
+
+ if (
+ !lazy.NetworkUtils.matchRequest(channel, {
+ targetActor: this.targetActor,
+ })
+ ) {
+ return;
+ }
+
+ // Only one network request should be created per URI for images from the cache
+ const hasURI = Array.from(this.networkEvents.values()).some(
+ networkEvent => networkEvent.uri === channel.URI.spec
+ );
+
+ if (hasURI) {
+ return;
+ }
+
+ this.onNetworkEventAvailable(channel, {
+ networkEventOptions: { fromCache: true },
+ });
+ }
+
+ onNetworkEventAvailable(channel, { networkEventOptions }) {
+ const actor = new NetworkEventActor(
+ this.targetActor.conn,
+ this.targetActor.sessionContext,
+ {
+ onNetworkEventUpdate: this.onNetworkEventUpdate.bind(this),
+ onNetworkEventDestroy: this.onNetworkEventDestroyed.bind(this),
+ },
+ networkEventOptions,
+ channel
+ );
+ this.targetActor.manage(actor);
+
+ const resource = actor.asResource();
+
+ const networkEvent = {
+ browsingContextID: resource.browsingContextID,
+ innerWindowId: resource.innerWindowId,
+ resourceId: resource.resourceId,
+ resourceType: resource.resourceType,
+ receivedUpdates: [],
+ resourceUpdates: {
+ // Requests already come with request cookies and headers, so those
+ // should always be considered as available. But the client still
+ // heavily relies on those `Available` flags to fetch additional data,
+ // so it is better to keep them for consistency.
+ requestCookiesAvailable: true,
+ requestHeadersAvailable: true,
+ },
+ uri: channel.URI.spec,
+ };
+ this.networkEvents.set(resource.resourceId, networkEvent);
+
+ this.onAvailable([resource]);
+ const isBlocked = !!resource.blockedReason;
+ if (isBlocked) {
+ this._emitUpdate(networkEvent);
+ } else {
+ actor.addResponseStart({ channel, fromCache: true });
+ actor.addEventTimings(
+ 0 /* totalTime */,
+ {} /* timings */,
+ {} /* offsets */
+ );
+ actor.addResponseContent(
+ {
+ mimeType: channel.contentType,
+ size: channel.contentLength,
+ text: "",
+ transferredSize: 0,
+ },
+ {}
+ );
+ }
+ }
+
+ onNetworkEventUpdate(updateResource) {
+ const networkEvent = this.networkEvents.get(updateResource.resourceId);
+
+ if (!networkEvent) {
+ return;
+ }
+
+ const { resourceUpdates, receivedUpdates } = networkEvent;
+
+ switch (updateResource.updateType) {
+ case "responseStart":
+ // For cached image requests channel.responseStatus is set to 200 as
+ // expected. However responseStatusText is empty. In this case fallback
+ // to the expected statusText "OK".
+ let statusText = updateResource.statusText;
+ if (!statusText && updateResource.status === "200") {
+ statusText = "OK";
+ }
+ resourceUpdates.httpVersion = updateResource.httpVersion;
+ resourceUpdates.status = updateResource.status;
+ resourceUpdates.statusText = statusText;
+ resourceUpdates.remoteAddress = updateResource.remoteAddress;
+ resourceUpdates.remotePort = updateResource.remotePort;
+ resourceUpdates.waitingTime = updateResource.waitingTime;
+
+ resourceUpdates.responseHeadersAvailable = true;
+ resourceUpdates.responseCookiesAvailable = true;
+ break;
+ case "responseContent":
+ resourceUpdates.contentSize = updateResource.contentSize;
+ resourceUpdates.mimeType = updateResource.mimeType;
+ resourceUpdates.transferredSize = updateResource.transferredSize;
+ break;
+ case "eventTimings":
+ resourceUpdates.totalTime = updateResource.totalTime;
+ break;
+ }
+
+ resourceUpdates[`${updateResource.updateType}Available`] = true;
+ receivedUpdates.push(updateResource.updateType);
+
+ // Here we explicitly call all three `add` helpers on each network event
+ // actor so in theory we could check only the last one to be called, ie
+ // responseContent.
+ const isComplete =
+ receivedUpdates.includes("responseStart") &&
+ receivedUpdates.includes("responseContent") &&
+ receivedUpdates.includes("eventTimings");
+
+ if (isComplete) {
+ this._emitUpdate(networkEvent);
+ }
+ }
+
+ _emitUpdate(networkEvent) {
+ this.onUpdated([
+ {
+ resourceType: networkEvent.resourceType,
+ resourceId: networkEvent.resourceId,
+ resourceUpdates: networkEvent.resourceUpdates,
+ browsingContextID: networkEvent.browsingContextID,
+ innerWindowId: networkEvent.innerWindowId,
+ },
+ ]);
+ }
+
+ onNetworkEventDestroyed(channelId) {
+ if (this.networkEvents.has(channelId)) {
+ this.networkEvents.delete(channelId);
+ }
+ }
+
+ destroy() {
+ this.clear();
+ Services.obs.removeObserver(
+ this.httpFailedOpeningRequest,
+ "http-on-failed-opening-request"
+ );
+
+ Services.obs.removeObserver(
+ this.httpOnImageCacheResponse,
+ "http-on-image-cache-response"
+ );
+ }
+}
+
+module.exports = NetworkEventContentWatcher;
diff --git a/devtools/server/actors/resources/network-events-stacktraces.js b/devtools/server/actors/resources/network-events-stacktraces.js
new file mode 100644
index 0000000000..a458278680
--- /dev/null
+++ b/devtools/server/actors/resources/network-events-stacktraces.js
@@ -0,0 +1,214 @@
+/* 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 {
+ TYPES: { NETWORK_EVENT_STACKTRACE },
+} = require("resource://devtools/server/actors/resources/index.js");
+
+loader.lazyRequireGetter(
+ this,
+ "ChannelEventSinkFactory",
+ "resource://devtools/server/actors/network-monitor/channel-event-sink.js",
+ true
+);
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ NetworkUtils:
+ "resource://devtools/shared/network-observer/NetworkUtils.sys.mjs",
+});
+
+class NetworkEventStackTracesWatcher {
+ /**
+ * Start watching for all network event's stack traces related to a given Target actor.
+ *
+ * @param TargetActor targetActor
+ * The target actor from which we should observe the strack traces
+ * @param Object options
+ * Dictionary object with following attributes:
+ * - onAvailable: mandatory
+ * This will be called for each resource.
+ */
+ async watch(targetActor, { onAvailable }) {
+ this.stacktraces = new Map();
+ this.onStackTraceAvailable = onAvailable;
+ this.targetActor = targetActor;
+
+ Services.obs.addObserver(this, "http-on-opening-request");
+ Services.obs.addObserver(this, "document-on-opening-request");
+ Services.obs.addObserver(this, "network-monitor-alternate-stack");
+ ChannelEventSinkFactory.getService().registerCollector(this);
+ }
+
+ /**
+ * Allows clearing of network stacktrace resources
+ */
+ clear() {
+ this.stacktraces.clear();
+ }
+
+ /**
+ * Stop watching for network event's strack traces related to a given Target Actor.
+ *
+ * @param TargetActor targetActor
+ * The target actor from which we should stop observing the strack traces
+ */
+ destroy(targetActor) {
+ this.clear();
+ Services.obs.removeObserver(this, "http-on-opening-request");
+ Services.obs.removeObserver(this, "document-on-opening-request");
+ Services.obs.removeObserver(this, "network-monitor-alternate-stack");
+ ChannelEventSinkFactory.getService().unregisterCollector(this);
+ }
+
+ onChannelRedirect(oldChannel, newChannel, flags) {
+ // We can be called with any nsIChannel, but are interested only in HTTP channels
+ try {
+ oldChannel.QueryInterface(Ci.nsIHttpChannel);
+ newChannel.QueryInterface(Ci.nsIHttpChannel);
+ } catch (ex) {
+ return;
+ }
+
+ const oldId = oldChannel.channelId;
+ const stacktrace = this.stacktraces.get(oldId);
+ if (stacktrace) {
+ this._setStackTrace(newChannel.channelId, stacktrace);
+ }
+ }
+
+ observe(subject, topic, data) {
+ let channel, id;
+ try {
+ // We need to QI nsIHttpChannel in order to load the interface's
+ // methods / attributes for later code that could assume we are dealing
+ // with a nsIHttpChannel.
+ channel = subject.QueryInterface(Ci.nsIHttpChannel);
+ id = channel.channelId;
+ } catch (e1) {
+ try {
+ channel = subject.QueryInterface(Ci.nsIIdentChannel);
+ id = channel.channelId;
+ } catch (e2) {
+ // WebSocketChannels do not have IDs, so use the serial. When a WebSocket is
+ // opened in a content process, a channel is created locally but the HTTP
+ // channel for the connection lives entirely in the parent process. When
+ // the server code running in the parent sees that HTTP channel, it will
+ // look for the creation stack using the websocket's serial.
+ try {
+ channel = subject.QueryInterface(Ci.nsIWebSocketChannel);
+ id = channel.serial;
+ } catch (e3) {
+ // Try if the channel is a nsIWorkerChannelInfo which is the substitute
+ // of the channel in the parent process.
+ try {
+ channel = subject.QueryInterface(Ci.nsIWorkerChannelInfo);
+ id = channel.channelId;
+ } catch (e4) {
+ // Channels which don't implement the above interfaces can appear here,
+ // such as nsIFileChannel. Ignore these channels.
+ return;
+ }
+ }
+ }
+ }
+
+ if (
+ !lazy.NetworkUtils.matchRequest(channel, {
+ targetActor: this.targetActor,
+ })
+ ) {
+ return;
+ }
+
+ if (this.stacktraces.has(id)) {
+ // We can get up to two stack traces for the same channel: one each from
+ // the two observer topics we are listening to. Use the first stack trace
+ // which is specified, and ignore any later one.
+ return;
+ }
+
+ const stacktrace = [];
+ switch (topic) {
+ case "http-on-opening-request":
+ case "document-on-opening-request": {
+ // The channel is being opened on the main thread, associate the current
+ // stack with it.
+ //
+ // Convert the nsIStackFrame XPCOM objects to a nice JSON that can be
+ // passed around through message managers etc.
+ let frame = Components.stack;
+ if (frame?.caller) {
+ frame = frame.caller;
+ while (frame) {
+ stacktrace.push({
+ filename: frame.filename,
+ lineNumber: frame.lineNumber,
+ columnNumber: frame.columnNumber,
+ functionName: frame.name,
+ asyncCause: frame.asyncCause,
+ });
+ frame = frame.caller || frame.asyncCaller;
+ }
+ }
+ break;
+ }
+ case "network-monitor-alternate-stack": {
+ // An alternate stack trace is being specified for this channel.
+ // The topic data is the JSON for the saved frame stack we should use,
+ // so convert this into the expected format.
+ //
+ // This topic is used in the following cases:
+ //
+ // - The HTTP channel is opened asynchronously or on a different thread
+ // from the code which triggered its creation, in which case the stack
+ // from Components.stack will be empty. The alternate stack will be
+ // for the point we want to associate with the channel.
+ //
+ // - The channel is not a nsIHttpChannel, and we will receive no
+ // opening request notification for it.
+ let frame = JSON.parse(data);
+ while (frame) {
+ stacktrace.push({
+ filename: frame.source,
+ lineNumber: frame.line,
+ columnNumber: frame.column,
+ functionName: frame.functionDisplayName,
+ asyncCause: frame.asyncCause,
+ });
+ frame = frame.parent || frame.asyncParent;
+ }
+ break;
+ }
+ default:
+ throw new Error("Unexpected observe() topic");
+ }
+
+ this._setStackTrace(id, stacktrace);
+ }
+
+ _setStackTrace(resourceId, stacktrace) {
+ this.stacktraces.set(resourceId, stacktrace);
+ this.onStackTraceAvailable([
+ {
+ resourceType: NETWORK_EVENT_STACKTRACE,
+ resourceId,
+ stacktraceAvailable: stacktrace && !!stacktrace.length,
+ lastFrame: stacktrace && stacktrace.length ? stacktrace[0] : undefined,
+ },
+ ]);
+ }
+
+ getStackTrace(id) {
+ let stacktrace = [];
+ if (this.stacktraces.has(id)) {
+ stacktrace = this.stacktraces.get(id);
+ }
+ return stacktrace;
+ }
+}
+module.exports = NetworkEventStackTracesWatcher;
diff --git a/devtools/server/actors/resources/network-events.js b/devtools/server/actors/resources/network-events.js
new file mode 100644
index 0000000000..e7edd3d3fe
--- /dev/null
+++ b/devtools/server/actors/resources/network-events.js
@@ -0,0 +1,414 @@
+/* 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 { Pool } = require("resource://devtools/shared/protocol/Pool.js");
+const { isWindowGlobalPartOfContext } = ChromeUtils.importESModule(
+ "resource://devtools/server/actors/watcher/browsing-context-helpers.sys.mjs"
+);
+const { WatcherRegistry } = ChromeUtils.importESModule(
+ "resource://devtools/server/actors/watcher/WatcherRegistry.sys.mjs",
+ {
+ // WatcherRegistry needs to be a true singleton and loads ActorManagerParent
+ // which also has to be a true singleton.
+ loadInDevToolsLoader: false,
+ }
+);
+const Targets = require("resource://devtools/server/actors/targets/index.js");
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ NetworkObserver:
+ "resource://devtools/shared/network-observer/NetworkObserver.sys.mjs",
+ NetworkUtils:
+ "resource://devtools/shared/network-observer/NetworkUtils.sys.mjs",
+});
+
+loader.lazyRequireGetter(
+ this,
+ "NetworkEventActor",
+ "resource://devtools/server/actors/network-monitor/network-event-actor.js",
+ true
+);
+
+/**
+ * Handles network events from the parent process
+ */
+class NetworkEventWatcher {
+ /**
+ * Start watching for all network events related to a given Watcher Actor.
+ *
+ * @param WatcherActor watcherActor
+ * The watcher actor in the parent process from which we should
+ * observe network events.
+ * @param Object options
+ * Dictionary object with following attributes:
+ * - onAvailable: mandatory function
+ * This will be called for each resource.
+ * - onUpdated: optional function
+ * This would be called multiple times for each resource.
+ */
+ async watch(watcherActor, { onAvailable, onUpdated }) {
+ this.networkEvents = new Map();
+
+ this.watcherActor = watcherActor;
+ this.onNetworkEventAvailable = onAvailable;
+ this.onNetworkEventUpdated = onUpdated;
+ // Boolean to know if we keep previous document network events or not.
+ this.persist = false;
+ this.listener = new lazy.NetworkObserver({
+ ignoreChannelFunction: this.shouldIgnoreChannel.bind(this),
+ onNetworkEvent: this.onNetworkEvent.bind(this),
+ });
+
+ Services.obs.addObserver(this, "window-global-destroyed");
+ }
+
+ /**
+ * Clear all the network events and the related actors.
+ *
+ * This is called on actor destroy, but also from WatcherActor.clearResources(NETWORK_EVENT)
+ */
+ clear() {
+ this.networkEvents.clear();
+ this.listener.clear();
+ if (this._pool) {
+ this._pool.destroy();
+ this._pool = null;
+ }
+ }
+
+ /**
+ * A protocol.js Pool to store all NetworkEventActor's which may be destroyed on navigations.
+ */
+ get pool() {
+ if (this._pool) {
+ return this._pool;
+ }
+ this._pool = new Pool(this.watcherActor.conn, "network-events");
+ this.watcherActor.manage(this._pool);
+ return this._pool;
+ }
+
+ /**
+ * Instruct to keep reference to previous document requests or not.
+ * If persist is disabled, we will clear all informations about previous document
+ * on each navigation.
+ * If persist is enabled, we will keep all informations for all documents, leading
+ * to lots of allocations!
+ *
+ * @param {Boolean} enabled
+ */
+ setPersist(enabled) {
+ this.persist = enabled;
+ }
+
+ /**
+ * Gets the throttle settings
+ *
+ * @return {*} data
+ *
+ */
+ getThrottleData() {
+ return this.listener.getThrottleData();
+ }
+
+ /**
+ * Sets the throttle data
+ *
+ * @param {*} data
+ *
+ */
+ setThrottleData(data) {
+ this.listener.setThrottleData(data);
+ }
+
+ /**
+ * Instruct to save or ignore request and response bodies
+ * @param {Boolean} save
+ */
+ setSaveRequestAndResponseBodies(save) {
+ this.listener.setSaveRequestAndResponseBodies(save);
+ }
+
+ /**
+ * Block requests based on the filters
+ * @param {Object} filters
+ */
+ blockRequest(filters) {
+ this.listener.blockRequest(filters);
+ }
+
+ /**
+ * Unblock requests based on the fitlers
+ * @param {Object} filters
+ */
+ unblockRequest(filters) {
+ this.listener.unblockRequest(filters);
+ }
+
+ /**
+ * Calls the listener to set blocked urls
+ *
+ * @param {Array} urls
+ * The urls to block
+ */
+
+ setBlockedUrls(urls) {
+ this.listener.setBlockedUrls(urls);
+ }
+
+ /**
+ * Calls the listener to get the blocked urls
+ *
+ * @return {Array} urls
+ * The blocked urls
+ */
+
+ getBlockedUrls() {
+ return this.listener.getBlockedUrls();
+ }
+
+ override(url, path) {
+ this.listener.override(url, path);
+ }
+
+ removeOverride(url) {
+ this.listener.removeOverride(url);
+ }
+
+ /**
+ * Watch for previous document being unloaded in order to clear
+ * all related network events, in case persist is disabled.
+ * (which is the default behavior)
+ */
+ observe(windowGlobal, topic) {
+ if (topic !== "window-global-destroyed") {
+ return;
+ }
+ // If we persist, we will keep all requests allocated.
+ // For now, consider that the Browser console and toolbox persist all the requests.
+ if (this.persist || this.watcherActor.sessionContext.type == "all") {
+ return;
+ }
+ // Only process WindowGlobals which are related to the debugged scope.
+ if (
+ !isWindowGlobalPartOfContext(
+ windowGlobal,
+ this.watcherActor.sessionContext
+ )
+ ) {
+ return;
+ }
+ const { innerWindowId } = windowGlobal;
+
+ for (const child of this.pool.poolChildren()) {
+ // Destroy all network events matching the destroyed WindowGlobal
+ if (!child.isNavigationRequest()) {
+ if (child.getInnerWindowId() == innerWindowId) {
+ child.destroy();
+ }
+ // Avoid destroying the navigation request, which is flagged with previous document's innerWindowId.
+ // When navigating, the WindowGlobal we navigate *from* will be destroyed and notified here.
+ // We should explicitly avoid destroying it here.
+ // But, we still want to eventually destroy them.
+ // So do this when navigating a second time, we will navigate from a distinct WindowGlobal
+ // and check that this is the top level window global and not an iframe one.
+ // So that we avoid clearing the top navigation when an iframe navigates
+ //
+ // Avoid destroying the request if innerWindowId isn't set. This happens when we reload many times in a row.
+ // The previous navigation request will be cancelled and because of that its innerWindowId will be null.
+ // But the frontend will receive it after the navigation begins (after will-navigate) and will display it
+ // and try to fetch extra data about it. So, avoid destroying its NetworkEventActor.
+ } else if (
+ child.getInnerWindowId() &&
+ child.getInnerWindowId() != innerWindowId &&
+ windowGlobal.browsingContext ==
+ this.watcherActor.browserElement?.browsingContext
+ ) {
+ child.destroy();
+ }
+ }
+ }
+
+ /**
+ * Called by NetworkObserver in order to know if the channel should be ignored
+ */
+ shouldIgnoreChannel(channel) {
+ // First of all, check if the channel matches the watcherActor's session.
+ const filters = { sessionContext: this.watcherActor.sessionContext };
+ if (!lazy.NetworkUtils.matchRequest(channel, filters)) {
+ return true;
+ }
+
+ // When we are in the browser toolbox in parent process scope,
+ // the session context is still "all", but we are no longer watching frame and process targets.
+ // In this case, we should ignore all requests belonging to a BrowsingContext that isn't in the parent process
+ // (i.e. the process where this Watcher runs)
+ const isParentProcessOnlyBrowserToolbox =
+ this.watcherActor.sessionContext.type == "all" &&
+ !WatcherRegistry.isWatchingTargets(
+ this.watcherActor,
+ Targets.TYPES.FRAME
+ );
+ if (isParentProcessOnlyBrowserToolbox) {
+ // We should ignore all requests coming from BrowsingContext running in another process
+ const browsingContextID =
+ lazy.NetworkUtils.getChannelBrowsingContextID(channel);
+ const browsingContext = BrowsingContext.get(browsingContextID);
+ // We accept any request that isn't bound to any BrowsingContext.
+ // This is most likely a privileged request done from a JSM/C++.
+ // `isInProcess` will be true, when the document executes in the parent process.
+ //
+ // Note that we will still accept all requests that aren't bound to any BrowsingContext
+ // See browser_resources_network_events_parent_process.js test with privileged request
+ // made from the content processes.
+ // We miss some attribute on channel/loadInfo to know that it comes from the content process.
+ if (browsingContext?.currentWindowGlobal.isInProcess === false) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ onNetworkEvent(networkEventOptions, channel) {
+ if (this.networkEvents.has(channel.channelId)) {
+ throw new Error(
+ `Got notified about channel ${channel.channelId} more than once.`
+ );
+ }
+
+ const actor = new NetworkEventActor(
+ this.watcherActor.conn,
+ this.watcherActor.sessionContext,
+ {
+ onNetworkEventUpdate: this.onNetworkEventUpdate.bind(this),
+ onNetworkEventDestroy: this.onNetworkEventDestroy.bind(this),
+ },
+ networkEventOptions,
+ channel
+ );
+ this.pool.manage(actor);
+
+ const resource = actor.asResource();
+ const isBlocked = !!resource.blockedReason;
+ const networkEvent = {
+ browsingContextID: resource.browsingContextID,
+ innerWindowId: resource.innerWindowId,
+ resourceId: resource.resourceId,
+ resourceType: resource.resourceType,
+ isBlocked,
+ receivedUpdates: [],
+ resourceUpdates: {
+ // Requests already come with request cookies and headers, so those
+ // should always be considered as available. But the client still
+ // heavily relies on those `Available` flags to fetch additional data,
+ // so it is better to keep them for consistency.
+ requestCookiesAvailable: true,
+ requestHeadersAvailable: true,
+ },
+ };
+ this.networkEvents.set(resource.resourceId, networkEvent);
+
+ this.onNetworkEventAvailable([resource]);
+
+ // Blocked requests will not receive further updates and should emit an
+ // update packet immediately.
+ // The frontend expects to receive a dedicated update to consider the
+ // request as completed. TODO: lift this restriction so that we can only
+ // emit a resource available notification if no update is needed.
+ if (isBlocked) {
+ this._emitUpdate(networkEvent);
+ }
+
+ return actor;
+ }
+
+ onNetworkEventUpdate(updateResource) {
+ const networkEvent = this.networkEvents.get(updateResource.resourceId);
+
+ if (!networkEvent) {
+ return;
+ }
+
+ const { resourceUpdates, receivedUpdates } = networkEvent;
+
+ switch (updateResource.updateType) {
+ case "responseStart":
+ resourceUpdates.httpVersion = updateResource.httpVersion;
+ resourceUpdates.status = updateResource.status;
+ resourceUpdates.statusText = updateResource.statusText;
+ resourceUpdates.remoteAddress = updateResource.remoteAddress;
+ resourceUpdates.remotePort = updateResource.remotePort;
+ // The mimetype is only set when then the contentType is available
+ // in the _onResponseHeader and not for cached/service worker requests
+ // in _httpResponseExaminer.
+ resourceUpdates.mimeType = updateResource.mimeType;
+ resourceUpdates.waitingTime = updateResource.waitingTime;
+
+ resourceUpdates.responseHeadersAvailable = true;
+ resourceUpdates.responseCookiesAvailable = true;
+ break;
+ case "responseContent":
+ resourceUpdates.contentSize = updateResource.contentSize;
+ resourceUpdates.transferredSize = updateResource.transferredSize;
+ resourceUpdates.mimeType = updateResource.mimeType;
+ resourceUpdates.blockingExtension = updateResource.blockingExtension;
+ resourceUpdates.blockedReason = updateResource.blockedReason;
+ break;
+ case "eventTimings":
+ resourceUpdates.totalTime = updateResource.totalTime;
+ break;
+ case "securityInfo":
+ resourceUpdates.securityState = updateResource.state;
+ resourceUpdates.isRacing = updateResource.isRacing;
+ break;
+ }
+
+ resourceUpdates[`${updateResource.updateType}Available`] = true;
+ receivedUpdates.push(updateResource.updateType);
+
+ const isComplete =
+ receivedUpdates.includes("eventTimings") &&
+ receivedUpdates.includes("responseContent") &&
+ receivedUpdates.includes("securityInfo");
+
+ if (isComplete) {
+ this._emitUpdate(networkEvent);
+ }
+ }
+
+ _emitUpdate(networkEvent) {
+ this.onNetworkEventUpdated([
+ {
+ resourceType: networkEvent.resourceType,
+ resourceId: networkEvent.resourceId,
+ resourceUpdates: networkEvent.resourceUpdates,
+ browsingContextID: networkEvent.browsingContextID,
+ innerWindowId: networkEvent.innerWindowId,
+ },
+ ]);
+ }
+
+ onNetworkEventDestroy(channelId) {
+ if (this.networkEvents.has(channelId)) {
+ this.networkEvents.delete(channelId);
+ }
+ }
+
+ /**
+ * Stop watching for network event related to a given Watcher Actor.
+ */
+ destroy() {
+ if (this.listener) {
+ this.clear();
+ this.listener.destroy();
+ Services.obs.removeObserver(this, "window-global-destroyed");
+ }
+ }
+}
+
+module.exports = NetworkEventWatcher;
diff --git a/devtools/server/actors/resources/parent-process-document-event.js b/devtools/server/actors/resources/parent-process-document-event.js
new file mode 100644
index 0000000000..e156a32fe5
--- /dev/null
+++ b/devtools/server/actors/resources/parent-process-document-event.js
@@ -0,0 +1,174 @@
+/* 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 {
+ TYPES: { DOCUMENT_EVENT },
+} = require("resource://devtools/server/actors/resources/index.js");
+const isEveryFrameTargetEnabled = Services.prefs.getBoolPref(
+ "devtools.every-frame-target.enabled",
+ false
+);
+const {
+ WILL_NAVIGATE_TIME_SHIFT,
+} = require("resource://devtools/server/actors/webconsole/listeners/document-events.js");
+
+class ParentProcessDocumentEventWatcher {
+ /**
+ * Start watching, from the parent process, for DOCUMENT_EVENT's "will-navigate" event related to a given Watcher Actor.
+ *
+ * All other DOCUMENT_EVENT events are implemented from another watcher class, running in the content process.
+ * Note that this other content process watcher will also emit one special edgecase of will-navigate
+ * retlated to the iframe dropdown menu.
+ *
+ * We have to move listen for navigation in the parent to better handle bfcache navigations
+ * and more generally all navigations which are initiated from the parent process.
+ * 'bfcacheInParent' feature enabled many types of navigations to be controlled from the parent process.
+ *
+ * This was especially important to have this implementation in the parent
+ * because the navigation event may be fired too late in the content process.
+ * Leading to will-navigate being emitted *after* the new target we navigate to is notified to the client.
+ *
+ * @param WatcherActor watcherActor
+ * The watcher actor from which we should observe document event
+ * @param Object options
+ * Dictionary object with following attributes:
+ * - onAvailable: mandatory function
+ * This will be called for each resource.
+ */
+ async watch(watcherActor, { onAvailable }) {
+ this.watcherActor = watcherActor;
+ this.onAvailable = onAvailable;
+
+ // List of listeners keyed by innerWindowId.
+ // Listeners are called as soon as we emitted the will-navigate
+ // resource for the related WindowGlobal.
+ this._onceWillNavigate = new Map();
+
+ // Filter browsing contexts to only have the top BrowsingContext of each tree of BrowsingContexts…
+ const topLevelBrowsingContexts = this.watcherActor
+ .getAllBrowsingContexts()
+ .filter(browsingContext => browsingContext.top == browsingContext);
+
+ // Only register one WebProgressListener per BrowsingContext tree.
+ // We will be notified about children BrowsingContext navigations/state changes via the top level BrowsingContextWebProgressListener,
+ // and BrowsingContextWebProgress.browsingContext attribute will be updated dynamically everytime
+ // we get notified about a child BrowsingContext.
+ // Note that regular web page toolbox will only have one BrowsingContext tree, for the given tab.
+ // But the Browser Toolbox will have many trees to listen to, one per top-level Window, and also one per tab,
+ // as tabs's BrowsingContext context aren't children of their top level window!
+ //
+ // Also save the WebProgress and not the BrowsingContext because `BrowsingContext.webProgress` will be undefined in destroy(),
+ // while it is still valuable to call `webProgress.removeProgressListener`. Otherwise events keeps being notified!!
+ this.webProgresses = topLevelBrowsingContexts.map(
+ browsingContext => browsingContext.webProgress
+ );
+ this.webProgresses.forEach(webProgress => {
+ webProgress.addProgressListener(
+ this,
+ Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT
+ );
+ });
+ }
+
+ /**
+ * Wait for the emission of will-navigate for a given WindowGlobal
+ *
+ * @param Number innerWindowId
+ * WindowGlobal's id we want to track
+ * @return Promise
+ * Resolves immediatly if the WindowGlobal isn't tracked by any target
+ * -or- resolve later, once the WindowGlobal navigates to another document
+ * and will-navigate has been emitted.
+ */
+ onceWillNavigateIsEmitted(innerWindowId) {
+ // Only delay the target-destroyed event if the target is for BrowsingContext for which we will emit will-navigate
+ const isTracked = this.webProgresses.find(
+ webProgress =>
+ webProgress.browsingContext.currentWindowGlobal.innerWindowId ==
+ innerWindowId
+ );
+ if (isTracked) {
+ return new Promise(resolve => {
+ this._onceWillNavigate.set(innerWindowId, resolve);
+ });
+ }
+ return Promise.resolve();
+ }
+
+ onStateChange(progress, request, flag, status) {
+ const isStart = flag & Ci.nsIWebProgressListener.STATE_START;
+ const isDocument = flag & Ci.nsIWebProgressListener.STATE_IS_DOCUMENT;
+ if (isDocument && isStart) {
+ const { browsingContext } = progress;
+ // Ignore navigation for same-process iframes when EFT is disabled
+ if (
+ !browsingContext.currentWindowGlobal.isProcessRoot &&
+ !isEveryFrameTargetEnabled
+ ) {
+ return;
+ }
+ // Ignore if we are still on the initial document,
+ // as that's the navigation from it (about:blank) to the actual first location.
+ // The target isn't created yet.
+ if (browsingContext.currentWindowGlobal.isInitialDocument) {
+ return;
+ }
+
+ // Only emit will-navigate for top-level targets.
+ if (
+ this.watcherActor.sessionContext.type == "all" &&
+ browsingContext.isContent
+ ) {
+ // Never emit will-navigate for content browsing contexts in the Browser Toolbox.
+ // They might verify `browsingContext.top == browsingContext` because of the chrome/content
+ // boundary, but they do not represent a top-level target for this DevTools session.
+ return;
+ }
+ const isTopLevel = browsingContext.top == browsingContext;
+ if (!isTopLevel) {
+ return;
+ }
+
+ const newURI = request instanceof Ci.nsIChannel ? request.URI.spec : null;
+ const { innerWindowId } = browsingContext.currentWindowGlobal;
+ this.onAvailable([
+ {
+ browsingContextID: browsingContext.id,
+ innerWindowId,
+ resourceType: DOCUMENT_EVENT,
+ name: "will-navigate",
+ time: Date.now() - WILL_NAVIGATE_TIME_SHIFT,
+ isFrameSwitching: false,
+ newURI,
+ },
+ ]);
+ const callback = this._onceWillNavigate.get(innerWindowId);
+ if (callback) {
+ this._onceWillNavigate.delete(innerWindowId);
+ callback();
+ }
+ }
+ }
+
+ get QueryInterface() {
+ return ChromeUtils.generateQI([
+ "nsIWebProgressListener",
+ "nsISupportsWeakReference",
+ ]);
+ }
+
+ destroy() {
+ this.webProgresses.forEach(webProgress => {
+ webProgress.removeProgressListener(
+ this,
+ Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT
+ );
+ });
+ this.webProgresses = null;
+ }
+}
+
+module.exports = ParentProcessDocumentEventWatcher;
diff --git a/devtools/server/actors/resources/platform-messages.js b/devtools/server/actors/resources/platform-messages.js
new file mode 100644
index 0000000000..6d9750c0a2
--- /dev/null
+++ b/devtools/server/actors/resources/platform-messages.js
@@ -0,0 +1,60 @@
+/* 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 nsIConsoleListenerWatcher = require("resource://devtools/server/actors/resources/utils/nsi-console-listener-watcher.js");
+
+const {
+ TYPES: { PLATFORM_MESSAGE },
+} = require("resource://devtools/server/actors/resources/index.js");
+
+const {
+ createStringGrip,
+} = require("resource://devtools/server/actors/object/utils.js");
+
+class PlatformMessageWatcher extends nsIConsoleListenerWatcher {
+ shouldHandleTarget(targetActor) {
+ return this.isProcessTarget(targetActor);
+ }
+
+ /**
+ * Returns true if the message is considered a platform message, and as a result, should
+ * be sent to the client.
+ *
+ * @param {TargetActor} targetActor
+ * @param {nsIConsoleMessage} message
+ */
+ shouldHandleMessage(targetActor, message) {
+ // The listener we use can be called either with a nsIConsoleMessage or as nsIScriptError.
+ // In this file, we want to ignore nsIScriptError, which are handled by the
+ // error-messages resource handler (See Bug 1644186).
+ if (message instanceof Ci.nsIScriptError) {
+ return false;
+ }
+
+ // Ignore message that were forwarded from the content process to the parent process,
+ // since we're getting those directly from the content process.
+ if (message.isForwardedFromContentProcess) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Returns an object from the nsIConsoleMessage.
+ *
+ * @param {Actor} targetActor
+ * @param {nsIConsoleMessage} message
+ */
+ buildResource(targetActor, message) {
+ return {
+ message: createStringGrip(targetActor, message.message),
+ timeStamp: message.microSecondTimeStamp / 1000,
+ resourceType: PLATFORM_MESSAGE,
+ };
+ }
+}
+module.exports = PlatformMessageWatcher;
diff --git a/devtools/server/actors/resources/reflow.js b/devtools/server/actors/resources/reflow.js
new file mode 100644
index 0000000000..5be9d6e7b2
--- /dev/null
+++ b/devtools/server/actors/resources/reflow.js
@@ -0,0 +1,63 @@
+/* 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 {
+ TYPES: { REFLOW },
+} = require("resource://devtools/server/actors/resources/index.js");
+const Targets = require("resource://devtools/server/actors/targets/index.js");
+
+const {
+ getLayoutChangesObserver,
+ releaseLayoutChangesObserver,
+} = require("resource://devtools/server/actors/reflow.js");
+
+class ReflowWatcher {
+ /**
+ * Start watching for reflows related to a given Target Actor.
+ *
+ * @param TargetActor targetActor
+ * The target actor from which we should observe reflows
+ * @param Object options
+ * Dictionary object with following attributes:
+ * - onAvailable: mandatory function
+ * This will be called for each resource.
+ */
+ async watch(targetActor, { onAvailable }) {
+ // Only track reflow for non-ParentProcess FRAME targets
+ if (
+ targetActor.targetType !== Targets.TYPES.FRAME ||
+ targetActor.typeName === "parentProcessTarget"
+ ) {
+ return;
+ }
+
+ this._targetActor = targetActor;
+
+ const onReflows = reflows => {
+ onAvailable([
+ {
+ resourceType: REFLOW,
+ reflows,
+ },
+ ]);
+ };
+
+ this._observer = getLayoutChangesObserver(targetActor);
+ this._offReflows = this._observer.on("reflows", onReflows);
+ this._observer.start();
+ }
+
+ destroy() {
+ releaseLayoutChangesObserver(this._targetActor);
+
+ if (this._offReflows) {
+ this._offReflows();
+ this._offReflows = null;
+ }
+ }
+}
+
+module.exports = ReflowWatcher;
diff --git a/devtools/server/actors/resources/server-sent-events.js b/devtools/server/actors/resources/server-sent-events.js
new file mode 100644
index 0000000000..5b16f8bb9f
--- /dev/null
+++ b/devtools/server/actors/resources/server-sent-events.js
@@ -0,0 +1,135 @@
+/* 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 {
+ LongStringActor,
+} = require("resource://devtools/server/actors/string.js");
+
+const {
+ TYPES: { SERVER_SENT_EVENT },
+} = require("resource://devtools/server/actors/resources/index.js");
+
+const eventSourceEventService = Cc[
+ "@mozilla.org/eventsourceevent/service;1"
+].getService(Ci.nsIEventSourceEventService);
+
+class ServerSentEventWatcher {
+ constructor() {
+ this.windowIds = new Set();
+ // Register for backend events.
+ this.onWindowReady = this.onWindowReady.bind(this);
+ this.onWindowDestroy = this.onWindowDestroy.bind(this);
+ }
+ /**
+ * Start watching for all server sent events related to a given Target Actor.
+ *
+ * @param TargetActor targetActor
+ * The target actor on which we should observe server sent events.
+ * @param Object options
+ * Dictionary object with following attributes:
+ * - onAvailable: mandatory function
+ * This will be called for each resource.
+ */
+ watch(targetActor, { onAvailable }) {
+ this.onAvailable = onAvailable;
+ this.targetActor = targetActor;
+
+ for (const window of this.targetActor.windows) {
+ const { innerWindowId } = window.windowGlobalChild;
+ this.startListening(innerWindowId);
+ }
+
+ // Listen for subsequent top-level-document reloads/navigations,
+ // new iframe additions or current iframe reloads/navigation.
+ this.targetActor.on("window-ready", this.onWindowReady);
+ this.targetActor.on("window-destroyed", this.onWindowDestroy);
+ }
+
+ static createResource(messageType, eventParams) {
+ return {
+ resourceType: SERVER_SENT_EVENT,
+ messageType,
+ ...eventParams,
+ };
+ }
+
+ static prepareFramePayload(targetActor, frame) {
+ const payload = new LongStringActor(targetActor.conn, frame);
+ targetActor.manage(payload);
+ return payload.form();
+ }
+
+ onWindowReady({ window }) {
+ const { innerWindowId } = window.windowGlobalChild;
+ this.startListening(innerWindowId);
+ }
+
+ onWindowDestroy({ id }) {
+ this.stopListening(id);
+ }
+
+ startListening(innerWindowId) {
+ if (!this.windowIds.has(innerWindowId)) {
+ this.windowIds.add(innerWindowId);
+ eventSourceEventService.addListener(innerWindowId, this);
+ }
+ }
+
+ stopListening(innerWindowId) {
+ if (this.windowIds.has(innerWindowId)) {
+ this.windowIds.delete(innerWindowId);
+ // The listener might have already been cleaned up on `window-destroy`.
+ if (!eventSourceEventService.hasListenerFor(innerWindowId)) {
+ console.warn(
+ "Already stopped listening to server sent events for this window."
+ );
+ return;
+ }
+ eventSourceEventService.removeListener(innerWindowId, this);
+ }
+ }
+
+ destroy() {
+ // cleanup any other listeners not removed on `window-destroy`
+ for (const id of this.windowIds) {
+ this.stopListening(id);
+ }
+ this.targetActor.off("window-ready", this.onWindowReady);
+ this.targetActor.off("window-destroyed", this.onWindowDestroy);
+ }
+
+ // nsIEventSourceEventService specific functions
+ eventSourceConnectionOpened(httpChannelId) {}
+
+ eventSourceConnectionClosed(httpChannelId) {
+ const resource = ServerSentEventWatcher.createResource(
+ "eventSourceConnectionClosed",
+ { httpChannelId }
+ );
+ this.onAvailable([resource]);
+ }
+
+ eventReceived(httpChannelId, eventName, lastEventId, data, retry, timeStamp) {
+ const payload = ServerSentEventWatcher.prepareFramePayload(
+ this.targetActor,
+ data
+ );
+ const resource = ServerSentEventWatcher.createResource("eventReceived", {
+ httpChannelId,
+ data: {
+ payload,
+ eventName,
+ lastEventId,
+ retry,
+ timeStamp,
+ },
+ });
+
+ this.onAvailable([resource]);
+ }
+}
+
+module.exports = ServerSentEventWatcher;
diff --git a/devtools/server/actors/resources/sources.js b/devtools/server/actors/resources/sources.js
new file mode 100644
index 0000000000..6076e333c9
--- /dev/null
+++ b/devtools/server/actors/resources/sources.js
@@ -0,0 +1,97 @@
+/* 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 {
+ TYPES: { SOURCE },
+} = require("resource://devtools/server/actors/resources/index.js");
+const Targets = require("resource://devtools/server/actors/targets/index.js");
+
+const {
+ STATES: THREAD_STATES,
+} = require("resource://devtools/server/actors/thread.js");
+
+/**
+ * Start watching for all JS sources related to a given Target Actor.
+ * This will notify about existing sources, but also the ones created in future.
+ *
+ * @param TargetActor targetActor
+ * The target actor from which we should observe sources
+ * @param Object options
+ * Dictionary object with following attributes:
+ * - onAvailable: mandatory function
+ * This will be called for each resource.
+ */
+class SourceWatcher {
+ constructor() {
+ this.onNewSource = this.onNewSource.bind(this);
+ }
+
+ async watch(targetActor, { onAvailable }) {
+ // When debugging the whole browser, we instantiate both content process and browsing context targets.
+ // But sources will only be debugged the content process target, even browsing context sources.
+ if (
+ targetActor.sessionContext.type == "all" &&
+ targetActor.targetType === Targets.TYPES.FRAME &&
+ targetActor.typeName != "parentProcessTarget"
+ ) {
+ return;
+ }
+
+ const { threadActor } = targetActor;
+ this.sourcesManager = targetActor.sourcesManager;
+ this.onAvailable = onAvailable;
+
+ // Disable `ThreadActor.newSource` RDP event in order to avoid unnecessary traffic
+ threadActor.disableNewSourceEvents();
+
+ threadActor.sourcesManager.on("newSource", this.onNewSource);
+
+ // If the thread actors isn't bootstraped yet,
+ // (this might be the case when this watcher is created on target creation)
+ // attach the thread actor automatically.
+ // Otherwise it would not notify about future sources.
+ // However, do not attach the thread actor for Workers. They use a codepath
+ // which releases the worker on `attach`. For them, the client will call `attach`. (bug 1691986)
+ // Content process targets don't have attach method or sequence.
+ // Instead their thread actor is instantiated immediately, when generating their
+ // form. Which is called immediately when we notify the target actor to the TargetList.
+ const isTargetCreation = threadActor.state == THREAD_STATES.DETACHED;
+ if (isTargetCreation && !targetActor.targetType.endsWith("worker")) {
+ await threadActor.attach({});
+ }
+
+ // Before fetching all sources, process existing ones.
+ // The ThreadActor is already up and running before this code runs
+ // and have sources already registered and for which newSource event already fired.
+ onAvailable(
+ threadActor.sourcesManager.iter().map(s => {
+ const resource = s.form();
+ resource.resourceType = SOURCE;
+ return resource;
+ })
+ );
+
+ // Requesting all sources should end up emitting newSource on threadActor.sourcesManager
+ threadActor.addAllSources();
+ }
+
+ /**
+ * Stop watching for sources
+ */
+ destroy() {
+ if (this.sourcesManager) {
+ this.sourcesManager.off("newSource", this.onNewSource);
+ }
+ }
+
+ onNewSource(source) {
+ const resource = source.form();
+ resource.resourceType = SOURCE;
+ this.onAvailable([resource]);
+ }
+}
+
+module.exports = SourceWatcher;
diff --git a/devtools/server/actors/resources/storage-cache.js b/devtools/server/actors/resources/storage-cache.js
new file mode 100644
index 0000000000..73a2bba40f
--- /dev/null
+++ b/devtools/server/actors/resources/storage-cache.js
@@ -0,0 +1,22 @@
+/* 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 {
+ TYPES: { CACHE_STORAGE },
+} = require("resource://devtools/server/actors/resources/index.js");
+
+const ContentProcessStorage = require("resource://devtools/server/actors/resources/utils/content-process-storage.js");
+const {
+ CacheStorageActor,
+} = require("resource://devtools/server/actors/resources/storage/cache.js");
+
+class CacheWatcher extends ContentProcessStorage {
+ constructor() {
+ super(CacheStorageActor, "Cache", CACHE_STORAGE);
+ }
+}
+
+module.exports = CacheWatcher;
diff --git a/devtools/server/actors/resources/storage-cookie.js b/devtools/server/actors/resources/storage-cookie.js
new file mode 100644
index 0000000000..8d847a5bf0
--- /dev/null
+++ b/devtools/server/actors/resources/storage-cookie.js
@@ -0,0 +1,22 @@
+/* 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 {
+ TYPES: { COOKIE },
+} = require("resource://devtools/server/actors/resources/index.js");
+
+const ParentProcessStorage = require("resource://devtools/server/actors/resources/utils/parent-process-storage.js");
+const {
+ CookiesStorageActor,
+} = require("resource://devtools/server/actors/resources/storage/cookies.js");
+
+class CookiesWatcher extends ParentProcessStorage {
+ constructor() {
+ super(CookiesStorageActor, "cookies", COOKIE);
+ }
+}
+
+module.exports = CookiesWatcher;
diff --git a/devtools/server/actors/resources/storage-extension.js b/devtools/server/actors/resources/storage-extension.js
new file mode 100644
index 0000000000..daacd40778
--- /dev/null
+++ b/devtools/server/actors/resources/storage-extension.js
@@ -0,0 +1,30 @@
+/* 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 {
+ TYPES: { EXTENSION_STORAGE },
+} = require("resource://devtools/server/actors/resources/index.js");
+
+const ParentProcessStorage = require("resource://devtools/server/actors/resources/utils/parent-process-storage.js");
+const {
+ ExtensionStorageActor,
+} = require("resource://devtools/server/actors/resources/storage/extension-storage.js");
+
+class ExtensionStorageWatcher extends ParentProcessStorage {
+ constructor() {
+ super(ExtensionStorageActor, "extensionStorage", EXTENSION_STORAGE);
+ }
+ async watch(watcherActor, { onAvailable }) {
+ if (watcherActor.sessionContext.type != "webextension") {
+ throw new Error(
+ "EXTENSION_STORAGE should only be listened when debugging a webextension"
+ );
+ }
+ return super.watch(watcherActor, { onAvailable });
+ }
+}
+
+module.exports = ExtensionStorageWatcher;
diff --git a/devtools/server/actors/resources/storage-indexed-db.js b/devtools/server/actors/resources/storage-indexed-db.js
new file mode 100644
index 0000000000..88ee01a000
--- /dev/null
+++ b/devtools/server/actors/resources/storage-indexed-db.js
@@ -0,0 +1,22 @@
+/* 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 {
+ TYPES: { INDEXED_DB },
+} = require("resource://devtools/server/actors/resources/index.js");
+
+const ParentProcessStorage = require("resource://devtools/server/actors/resources/utils/parent-process-storage.js");
+const {
+ IndexedDBStorageActor,
+} = require("resource://devtools/server/actors/resources/storage/indexed-db.js");
+
+class IndexedDBWatcher extends ParentProcessStorage {
+ constructor() {
+ super(IndexedDBStorageActor, "indexedDB", INDEXED_DB);
+ }
+}
+
+module.exports = IndexedDBWatcher;
diff --git a/devtools/server/actors/resources/storage-local-storage.js b/devtools/server/actors/resources/storage-local-storage.js
new file mode 100644
index 0000000000..54b5ea4d5b
--- /dev/null
+++ b/devtools/server/actors/resources/storage-local-storage.js
@@ -0,0 +1,22 @@
+/* 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 {
+ TYPES: { LOCAL_STORAGE },
+} = require("resource://devtools/server/actors/resources/index.js");
+
+const ContentProcessStorage = require("resource://devtools/server/actors/resources/utils/content-process-storage.js");
+const {
+ LocalStorageActor,
+} = require("resource://devtools/server/actors/resources/storage/local-and-session-storage.js");
+
+class LocalStorageWatcher extends ContentProcessStorage {
+ constructor() {
+ super(LocalStorageActor, "localStorage", LOCAL_STORAGE);
+ }
+}
+
+module.exports = LocalStorageWatcher;
diff --git a/devtools/server/actors/resources/storage-session-storage.js b/devtools/server/actors/resources/storage-session-storage.js
new file mode 100644
index 0000000000..fa980aa9f1
--- /dev/null
+++ b/devtools/server/actors/resources/storage-session-storage.js
@@ -0,0 +1,22 @@
+/* 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 {
+ TYPES: { SESSION_STORAGE },
+} = require("resource://devtools/server/actors/resources/index.js");
+
+const ContentProcessStorage = require("resource://devtools/server/actors/resources/utils/content-process-storage.js");
+const {
+ SessionStorageActor,
+} = require("resource://devtools/server/actors/resources/storage/local-and-session-storage.js");
+
+class SessionStorageWatcher extends ContentProcessStorage {
+ constructor() {
+ super(SessionStorageActor, "sessionStorage", SESSION_STORAGE);
+ }
+}
+
+module.exports = SessionStorageWatcher;
diff --git a/devtools/server/actors/resources/storage/cache.js b/devtools/server/actors/resources/storage/cache.js
new file mode 100644
index 0000000000..2066d181e0
--- /dev/null
+++ b/devtools/server/actors/resources/storage/cache.js
@@ -0,0 +1,195 @@
+/* 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 {
+ BaseStorageActor,
+} = require("resource://devtools/server/actors/resources/storage/index.js");
+
+class CacheStorageActor extends BaseStorageActor {
+ constructor(storageActor) {
+ super(storageActor, "Cache");
+ }
+
+ async populateStoresForHost(host) {
+ const storeMap = new Map();
+ const caches = await this.getCachesForHost(host);
+ try {
+ for (const name of await caches.keys()) {
+ storeMap.set(name, await caches.open(name));
+ }
+ } catch (ex) {
+ console.warn(`Failed to enumerate CacheStorage for host ${host}: ${ex}`);
+ }
+ this.hostVsStores.set(host, storeMap);
+ }
+
+ async getCachesForHost(host) {
+ const win = this.storageActor.getWindowFromHost(host);
+ if (!win) {
+ return null;
+ }
+
+ const principal = win.document.effectiveStoragePrincipal;
+
+ // The first argument tells if you want to get |content| cache or |chrome|
+ // cache.
+ // The |content| cache is the cache explicitely named by the web content
+ // (service worker or web page).
+ // The |chrome| cache is the cache implicitely cached by the platform,
+ // hosting the source file of the service worker.
+ const { CacheStorage } = win;
+
+ if (!CacheStorage) {
+ return null;
+ }
+
+ const cache = new CacheStorage("content", principal);
+ return cache;
+ }
+
+ form() {
+ const hosts = {};
+ for (const host of this.hosts) {
+ hosts[host] = this.getNamesForHost(host);
+ }
+
+ return {
+ actor: this.actorID,
+ hosts,
+ traits: this._getTraits(),
+ };
+ }
+
+ getNamesForHost(host) {
+ // UI code expect each name to be a JSON string of an array :/
+ return [...this.hostVsStores.get(host).keys()].map(a => {
+ return JSON.stringify([a]);
+ });
+ }
+
+ async getValuesForHost(host, name) {
+ if (!name) {
+ // if we get here, we most likely clicked on the refresh button
+ // which called getStoreObjects, itself calling this method,
+ // all that, without having selected any particular cache name.
+ //
+ // Try to detect if a new cache has been added and notify the client
+ // asynchronously, via a RDP event.
+ const previousCaches = [...this.hostVsStores.get(host).keys()];
+ await this.populateStoresForHosts();
+ const updatedCaches = [...this.hostVsStores.get(host).keys()];
+ const newCaches = updatedCaches.filter(
+ cacheName => !previousCaches.includes(cacheName)
+ );
+ newCaches.forEach(cacheName =>
+ this.onItemUpdated("added", host, [cacheName])
+ );
+ const removedCaches = previousCaches.filter(
+ cacheName => !updatedCaches.includes(cacheName)
+ );
+ removedCaches.forEach(cacheName =>
+ this.onItemUpdated("deleted", host, [cacheName])
+ );
+ return [];
+ }
+ // UI is weird and expect a JSON stringified array... and pass it back :/
+ name = JSON.parse(name)[0];
+
+ const cache = this.hostVsStores.get(host).get(name);
+ const requests = await cache.keys();
+ const results = [];
+ for (const request of requests) {
+ let response = await cache.match(request);
+ // Unwrap the response to get access to all its properties if the
+ // response happen to be 'opaque', when it is a Cross Origin Request.
+ response = response.cloneUnfiltered();
+ results.push(await this.processEntry(request, response));
+ }
+ return results;
+ }
+
+ async processEntry(request, response) {
+ return {
+ url: String(request.url),
+ status: String(response.statusText),
+ };
+ }
+
+ async getFields() {
+ return [
+ { name: "url", editable: false },
+ { name: "status", editable: false },
+ ];
+ }
+
+ /**
+ * Given a url, correctly determine its protocol + hostname part.
+ */
+ getSchemaAndHost(url) {
+ const uri = Services.io.newURI(url);
+ return uri.scheme + "://" + uri.hostPort;
+ }
+
+ toStoreObject(item) {
+ return item;
+ }
+
+ async removeItem(host, name) {
+ const cacheMap = this.hostVsStores.get(host);
+ if (!cacheMap) {
+ return;
+ }
+
+ const parsedName = JSON.parse(name);
+
+ if (parsedName.length == 1) {
+ // Delete the whole Cache object
+ const [cacheName] = parsedName;
+ cacheMap.delete(cacheName);
+ const cacheStorage = await this.getCachesForHost(host);
+ await cacheStorage.delete(cacheName);
+ this.onItemUpdated("deleted", host, [cacheName]);
+ } else if (parsedName.length == 2) {
+ // Delete one cached request
+ const [cacheName, url] = parsedName;
+ const cache = cacheMap.get(cacheName);
+ if (cache) {
+ await cache.delete(url);
+ this.onItemUpdated("deleted", host, [cacheName, url]);
+ }
+ }
+ }
+
+ async removeAll(host, name) {
+ const cacheMap = this.hostVsStores.get(host);
+ if (!cacheMap) {
+ return;
+ }
+
+ const parsedName = JSON.parse(name);
+
+ // Only a Cache object is a valid object to clear
+ if (parsedName.length == 1) {
+ const [cacheName] = parsedName;
+ const cache = cacheMap.get(cacheName);
+ if (cache) {
+ const keys = await cache.keys();
+ await Promise.all(keys.map(key => cache.delete(key)));
+ this.onItemUpdated("cleared", host, [cacheName]);
+ }
+ }
+ }
+
+ /**
+ * CacheStorage API doesn't support any notifications, we must fake them
+ */
+ onItemUpdated(action, host, path) {
+ this.storageActor.update(action, "Cache", {
+ [host]: [JSON.stringify(path)],
+ });
+ }
+}
+exports.CacheStorageActor = CacheStorageActor;
diff --git a/devtools/server/actors/resources/storage/cookies.js b/devtools/server/actors/resources/storage/cookies.js
new file mode 100644
index 0000000000..35bc1dc21e
--- /dev/null
+++ b/devtools/server/actors/resources/storage/cookies.js
@@ -0,0 +1,554 @@
+/* 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 {
+ BaseStorageActor,
+ DEFAULT_VALUE,
+ SEPARATOR_GUID,
+} = require("resource://devtools/server/actors/resources/storage/index.js");
+const {
+ LongStringActor,
+} = require("resource://devtools/server/actors/string.js");
+
+// "Lax", "Strict" and "None" are special values of the SameSite property
+// that should not be translated.
+const COOKIE_SAMESITE = {
+ LAX: "Lax",
+ STRICT: "Strict",
+ NONE: "None",
+};
+
+// MAX_COOKIE_EXPIRY should be 2^63-1, but JavaScript can't handle that
+// precision.
+const MAX_COOKIE_EXPIRY = Math.pow(2, 62);
+
+/**
+ * General helpers
+ */
+function trimHttpHttpsPort(url) {
+ const match = url.match(/(.+):\d+$/);
+
+ if (match) {
+ url = match[1];
+ }
+ if (url.startsWith("http://")) {
+ return url.substr(7);
+ }
+ if (url.startsWith("https://")) {
+ return url.substr(8);
+ }
+ return url;
+}
+
+class CookiesStorageActor extends BaseStorageActor {
+ constructor(storageActor) {
+ super(storageActor, "cookies");
+
+ Services.obs.addObserver(this, "cookie-changed");
+ Services.obs.addObserver(this, "private-cookie-changed");
+ }
+
+ destroy() {
+ Services.obs.removeObserver(this, "cookie-changed");
+ Services.obs.removeObserver(this, "private-cookie-changed");
+
+ super.destroy();
+ }
+
+ populateStoresForHost(host) {
+ this.hostVsStores.set(host, new Map());
+
+ const originAttributes = this.getOriginAttributesFromHost(host);
+ const cookies = this.getCookiesFromHost(host, originAttributes);
+
+ for (const cookie of cookies) {
+ if (this.isCookieAtHost(cookie, host)) {
+ const uniqueKey =
+ `${cookie.name}${SEPARATOR_GUID}${cookie.host}` +
+ `${SEPARATOR_GUID}${cookie.path}`;
+
+ this.hostVsStores.get(host).set(uniqueKey, cookie);
+ }
+ }
+ }
+
+ getOriginAttributesFromHost(host) {
+ const win = this.storageActor.getWindowFromHost(host);
+ let originAttributes;
+ if (win) {
+ originAttributes =
+ win.document.effectiveStoragePrincipal.originAttributes;
+ } else {
+ // If we can't find the window by host, fallback to the top window
+ // origin attributes.
+ originAttributes =
+ this.storageActor.document?.effectiveStoragePrincipal.originAttributes;
+ }
+
+ return originAttributes;
+ }
+
+ getCookiesFromHost(host, originAttributes) {
+ // Local files have no host.
+ if (host.startsWith("file:///")) {
+ host = "";
+ }
+
+ host = trimHttpHttpsPort(host);
+
+ return Services.cookies.getCookiesFromHost(host, originAttributes);
+ }
+
+ /**
+ * Given a cookie object, figure out all the matching hosts from the page that
+ * the cookie belong to.
+ */
+ getMatchingHosts(cookies) {
+ if (!cookies.length) {
+ cookies = [cookies];
+ }
+ const hosts = new Set();
+ for (const host of this.hosts) {
+ for (const cookie of cookies) {
+ if (this.isCookieAtHost(cookie, host)) {
+ hosts.add(host);
+ }
+ }
+ }
+ return [...hosts];
+ }
+
+ /**
+ * Given a cookie object and a host, figure out if the cookie is valid for
+ * that host.
+ */
+ isCookieAtHost(cookie, host) {
+ if (cookie.host == null) {
+ return host == null;
+ }
+
+ host = trimHttpHttpsPort(host);
+
+ if (cookie.host.startsWith(".")) {
+ return ("." + host).endsWith(cookie.host);
+ }
+ if (cookie.host === "") {
+ return host.startsWith("file://" + cookie.path);
+ }
+
+ return cookie.host == host;
+ }
+
+ toStoreObject(cookie) {
+ if (!cookie) {
+ return null;
+ }
+
+ return {
+ uniqueKey:
+ `${cookie.name}${SEPARATOR_GUID}${cookie.host}` +
+ `${SEPARATOR_GUID}${cookie.path}`,
+ name: cookie.name,
+ host: cookie.host || "",
+ path: cookie.path || "",
+
+ // because expires is in seconds
+ expires: (cookie.expires || 0) * 1000,
+
+ // because creationTime is in micro seconds
+ creationTime: cookie.creationTime / 1000,
+
+ size: cookie.name.length + (cookie.value || "").length,
+
+ // - do -
+ lastAccessed: cookie.lastAccessed / 1000,
+ value: new LongStringActor(this.conn, cookie.value || ""),
+ hostOnly: !cookie.isDomain,
+ isSecure: cookie.isSecure,
+ isHttpOnly: cookie.isHttpOnly,
+ sameSite: this.getSameSiteStringFromCookie(cookie),
+ };
+ }
+
+ getSameSiteStringFromCookie(cookie) {
+ switch (cookie.sameSite) {
+ case cookie.SAMESITE_LAX:
+ return COOKIE_SAMESITE.LAX;
+ case cookie.SAMESITE_STRICT:
+ return COOKIE_SAMESITE.STRICT;
+ }
+ // cookie.SAMESITE_NONE
+ return COOKIE_SAMESITE.NONE;
+ }
+
+ /**
+ * Notification observer for "cookie-change".
+ *
+ * @param subject
+ * {Cookie|[Array]} A JSON parsed object containing either a single
+ * cookie representation or an array. Array is only in case of
+ * a "batch-deleted" action.
+ * @param {string} topic
+ * The topic of the notification.
+ * @param {string} action
+ * Additional data associated with the notification. Its the type of
+ * cookie change in the "cookie-change" topic.
+ */
+ onCookieChanged(subject, topic, action) {
+ if (
+ (topic !== "cookie-changed" && topic !== "private-cookie-changed") ||
+ !this.storageActor ||
+ !this.storageActor.windows
+ ) {
+ return null;
+ }
+
+ const hosts = this.getMatchingHosts(subject);
+ const data = {};
+
+ switch (action) {
+ case "added":
+ case "changed":
+ if (hosts.length) {
+ for (const host of hosts) {
+ const uniqueKey =
+ `${subject.name}${SEPARATOR_GUID}${subject.host}` +
+ `${SEPARATOR_GUID}${subject.path}`;
+
+ this.hostVsStores.get(host).set(uniqueKey, subject);
+ data[host] = [uniqueKey];
+ }
+ this.storageActor.update(action, "cookies", data);
+ }
+ break;
+
+ case "deleted":
+ if (hosts.length) {
+ for (const host of hosts) {
+ const uniqueKey =
+ `${subject.name}${SEPARATOR_GUID}${subject.host}` +
+ `${SEPARATOR_GUID}${subject.path}`;
+
+ this.hostVsStores.get(host).delete(uniqueKey);
+ data[host] = [uniqueKey];
+ }
+ this.storageActor.update("deleted", "cookies", data);
+ }
+ break;
+
+ case "batch-deleted":
+ if (hosts.length) {
+ for (const host of hosts) {
+ const stores = [];
+ for (const cookie of subject) {
+ const uniqueKey =
+ `${cookie.name}${SEPARATOR_GUID}${cookie.host}` +
+ `${SEPARATOR_GUID}${cookie.path}`;
+
+ this.hostVsStores.get(host).delete(uniqueKey);
+ stores.push(uniqueKey);
+ }
+ data[host] = stores;
+ }
+ this.storageActor.update("deleted", "cookies", data);
+ }
+ break;
+
+ case "cleared":
+ if (hosts.length) {
+ for (const host of hosts) {
+ data[host] = [];
+ }
+ this.storageActor.update("cleared", "cookies", data);
+ }
+ break;
+ }
+ return null;
+ }
+
+ async getFields() {
+ return [
+ { name: "uniqueKey", editable: false, private: true },
+ { name: "name", editable: true, hidden: false },
+ { name: "value", editable: true, hidden: false },
+ { name: "host", editable: true, hidden: false },
+ { name: "path", editable: true, hidden: false },
+ { name: "expires", editable: true, hidden: false },
+ { name: "size", editable: false, hidden: false },
+ { name: "isHttpOnly", editable: true, hidden: false },
+ { name: "isSecure", editable: true, hidden: false },
+ { name: "sameSite", editable: false, hidden: false },
+ { name: "lastAccessed", editable: false, hidden: false },
+ { name: "creationTime", editable: false, hidden: true },
+ { name: "hostOnly", editable: false, hidden: true },
+ ];
+ }
+
+ /**
+ * Pass the editItem command from the content to the chrome process.
+ *
+ * @param {Object} data
+ * See editCookie() for format details.
+ */
+ async editItem(data) {
+ data.originAttributes = this.getOriginAttributesFromHost(data.host);
+ this.editCookie(data);
+ }
+
+ async addItem(guid, host) {
+ const window = this.storageActor.getWindowFromHost(host);
+ const principal = window.document.effectiveStoragePrincipal;
+ this.addCookie(guid, principal);
+ }
+
+ async removeItem(host, name) {
+ const originAttributes = this.getOriginAttributesFromHost(host);
+ this.removeCookie(host, name, originAttributes);
+ }
+
+ async removeAll(host, domain) {
+ const originAttributes = this.getOriginAttributesFromHost(host);
+ this.removeAllCookies(host, domain, originAttributes);
+ }
+
+ async removeAllSessionCookies(host, domain) {
+ const originAttributes = this.getOriginAttributesFromHost(host);
+ this._removeCookies(host, { domain, originAttributes, session: true });
+ }
+
+ addCookie(guid, principal) {
+ // Set expiry time for cookie 1 day into the future
+ // NOTE: Services.cookies.add expects the time in seconds.
+ const ONE_DAY_IN_SECONDS = 60 * 60 * 24;
+ const time = Math.floor(Date.now() / 1000);
+ const expiry = time + ONE_DAY_IN_SECONDS;
+
+ // principal throws an error when we try to access principal.host if it
+ // does not exist (which happens at about: pages).
+ // We check for asciiHost instead, which is always present, and has a
+ // value of "" when the host is not available.
+ const domain = principal.asciiHost ? principal.host : principal.baseDomain;
+ const path = principal.filePath.startsWith("/") ? principal.filePath : "/";
+
+ Services.cookies.add(
+ domain,
+ path,
+ guid, // name
+ DEFAULT_VALUE, // value
+ false, // isSecure
+ false, // isHttpOnly,
+ false, // isSession,
+ expiry, // expires,
+ principal.originAttributes, // originAttributes
+ Ci.nsICookie.SAMESITE_LAX, // sameSite
+ principal.scheme === "https" // schemeMap
+ ? Ci.nsICookie.SCHEME_HTTPS
+ : Ci.nsICookie.SCHEME_HTTP
+ );
+ }
+
+ /**
+ * Apply the results of a cookie edit.
+ *
+ * @param {Object} data
+ * An object in the following format:
+ * {
+ * host: "http://www.mozilla.org",
+ * field: "value",
+ * editCookie: "name",
+ * oldValue: "%7BHello%7D",
+ * newValue: "%7BHelloo%7D",
+ * items: {
+ * name: "optimizelyBuckets",
+ * path: "/",
+ * host: ".mozilla.org",
+ * expires: "Mon, 02 Jun 2025 12:37:37 GMT",
+ * creationTime: "Tue, 18 Nov 2014 16:21:18 GMT",
+ * lastAccessed: "Wed, 17 Feb 2016 10:06:23 GMT",
+ * value: "%7BHelloo%7D",
+ * isDomain: "true",
+ * isSecure: "false",
+ * isHttpOnly: "false"
+ * }
+ * }
+ */
+ // eslint-disable-next-line complexity
+ editCookie(data) {
+ let { field, oldValue, newValue } = data;
+ const origName = field === "name" ? oldValue : data.items.name;
+ const origHost = field === "host" ? oldValue : data.items.host;
+ const origPath = field === "path" ? oldValue : data.items.path;
+ let cookie = null;
+
+ const cookies = Services.cookies.getCookiesFromHost(
+ origHost,
+ data.originAttributes || {}
+ );
+ for (const nsiCookie of cookies) {
+ if (
+ nsiCookie.name === origName &&
+ nsiCookie.host === origHost &&
+ nsiCookie.path === origPath
+ ) {
+ cookie = {
+ host: nsiCookie.host,
+ path: nsiCookie.path,
+ name: nsiCookie.name,
+ value: nsiCookie.value,
+ isSecure: nsiCookie.isSecure,
+ isHttpOnly: nsiCookie.isHttpOnly,
+ isSession: nsiCookie.isSession,
+ expires: nsiCookie.expires,
+ originAttributes: nsiCookie.originAttributes,
+ schemeMap: nsiCookie.schemeMap,
+ };
+ break;
+ }
+ }
+
+ if (!cookie) {
+ return;
+ }
+
+ // If the date is expired set it for 10 seconds in the future.
+ const now = new Date();
+ if (!cookie.isSession && cookie.expires * 1000 <= now) {
+ const tenSecondsFromNow = (now.getTime() + 10 * 1000) / 1000;
+
+ cookie.expires = tenSecondsFromNow;
+ }
+
+ switch (field) {
+ case "isSecure":
+ case "isHttpOnly":
+ case "isSession":
+ newValue = newValue === "true";
+ break;
+
+ case "expires":
+ newValue = Date.parse(newValue) / 1000;
+
+ if (isNaN(newValue)) {
+ newValue = MAX_COOKIE_EXPIRY;
+ }
+ break;
+
+ case "host":
+ case "name":
+ case "path":
+ // Remove the edited cookie.
+ Services.cookies.remove(
+ origHost,
+ origName,
+ origPath,
+ cookie.originAttributes
+ );
+ break;
+ }
+
+ // Apply changes.
+ cookie[field] = newValue;
+
+ // cookie.isSession is not always set correctly on session cookies so we
+ // need to trust cookie.expires instead.
+ cookie.isSession = !cookie.expires;
+
+ // Add the edited cookie.
+ Services.cookies.add(
+ cookie.host,
+ cookie.path,
+ cookie.name,
+ cookie.value,
+ cookie.isSecure,
+ cookie.isHttpOnly,
+ cookie.isSession,
+ cookie.isSession ? MAX_COOKIE_EXPIRY : cookie.expires,
+ cookie.originAttributes,
+ cookie.sameSite,
+ cookie.schemeMap
+ );
+ }
+
+ _removeCookies(host, opts = {}) {
+ // We use a uniqueId to emulate compound keys for cookies. We need to
+ // extract the cookie name to remove the correct cookie.
+ if (opts.name) {
+ const split = opts.name.split(SEPARATOR_GUID);
+
+ opts.name = split[0];
+ opts.path = split[2];
+ }
+
+ host = trimHttpHttpsPort(host);
+
+ function hostMatches(cookieHost, matchHost) {
+ if (cookieHost == null) {
+ return matchHost == null;
+ }
+ if (cookieHost.startsWith(".")) {
+ return ("." + matchHost).endsWith(cookieHost);
+ }
+ return cookieHost == host;
+ }
+
+ const cookies = Services.cookies.getCookiesFromHost(
+ host,
+ opts.originAttributes || {}
+ );
+ for (const cookie of cookies) {
+ if (
+ hostMatches(cookie.host, host) &&
+ (!opts.name || cookie.name === opts.name) &&
+ (!opts.domain || cookie.host === opts.domain) &&
+ (!opts.path || cookie.path === opts.path) &&
+ (!opts.session || (!cookie.expires && !cookie.maxAge))
+ ) {
+ Services.cookies.remove(
+ cookie.host,
+ cookie.name,
+ cookie.path,
+ cookie.originAttributes
+ );
+ }
+ }
+ }
+
+ removeCookie(host, name, originAttributes) {
+ if (name !== undefined) {
+ this._removeCookies(host, { name, originAttributes });
+ }
+ }
+
+ removeAllCookies(host, domain, originAttributes) {
+ this._removeCookies(host, { domain, originAttributes });
+ }
+
+ observe(subject, topic, data) {
+ if (
+ !subject ||
+ (topic != "cookie-changed" && topic != "private-cookie-changed")
+ ) {
+ return;
+ }
+
+ if (data === "batch-deleted") {
+ const cookiesNoInterface = subject.QueryInterface(Ci.nsIArray);
+ const cookies = [];
+
+ for (let i = 0; i < cookiesNoInterface.length; i++) {
+ const cookie = cookiesNoInterface.queryElementAt(i, Ci.nsICookie);
+ cookies.push(cookie);
+ }
+ this.onCookieChanged(cookies, topic, data);
+
+ return;
+ }
+
+ const cookie = subject.QueryInterface(Ci.nsICookie);
+ this.onCookieChanged(cookie, topic, data);
+ }
+}
+exports.CookiesStorageActor = CookiesStorageActor;
diff --git a/devtools/server/actors/resources/storage/extension-storage.js b/devtools/server/actors/resources/storage/extension-storage.js
new file mode 100644
index 0000000000..d14d3320c7
--- /dev/null
+++ b/devtools/server/actors/resources/storage/extension-storage.js
@@ -0,0 +1,491 @@
+/* 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 {
+ BaseStorageActor,
+} = require("resource://devtools/server/actors/resources/storage/index.js");
+const {
+ parseItemValue,
+} = require("resource://devtools/shared/storage/utils.js");
+const {
+ LongStringActor,
+} = require("resource://devtools/server/actors/string.js");
+// Use loadInDevToolsLoader: false for these extension modules, because these
+// are singletons with shared state, and we must not create a new instance if a
+// dedicated loader was used to load this module.
+loader.lazyGetter(this, "ExtensionParent", () => {
+ return ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs",
+ { loadInDevToolsLoader: false }
+ ).ExtensionParent;
+});
+loader.lazyGetter(this, "ExtensionProcessScript", () => {
+ return ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionProcessScript.sys.mjs",
+ { loadInDevToolsLoader: false }
+ ).ExtensionProcessScript;
+});
+loader.lazyGetter(this, "ExtensionStorageIDB", () => {
+ return ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionStorageIDB.sys.mjs",
+ { loadInDevToolsLoader: false }
+ ).ExtensionStorageIDB;
+});
+
+/**
+ * The Extension Storage actor.
+ */
+class ExtensionStorageActor extends BaseStorageActor {
+ constructor(storageActor) {
+ super(storageActor, "extensionStorage");
+
+ this.addonId = this.storageActor.parentActor.addonId;
+
+ // Retrieve the base moz-extension url for the extension
+ // (and also remove the final '/' from it).
+ this.extensionHostURL = this.getExtensionPolicy().getURL().slice(0, -1);
+
+ // Map<host, ExtensionStorageIDB db connection>
+ // Bug 1542038, 1542039: Each storage area will need its own
+ // dbConnectionForHost, as they each have different storage backends.
+ // Anywhere dbConnectionForHost is used, we need to know the storage
+ // area to access the correct database.
+ this.dbConnectionForHost = new Map();
+
+ this.onExtensionStartup = this.onExtensionStartup.bind(this);
+
+ this.onStorageChange = this.onStorageChange.bind(this);
+ }
+
+ getExtensionPolicy() {
+ return WebExtensionPolicy.getByID(this.addonId);
+ }
+
+ destroy() {
+ ExtensionStorageIDB.removeOnChangedListener(
+ this.addonId,
+ this.onStorageChange
+ );
+ ExtensionParent.apiManager.off("startup", this.onExtensionStartup);
+
+ super.destroy();
+ }
+
+ /**
+ * We need to override this method as we ignore BaseStorageActor's hosts
+ * and only care about the extension host.
+ */
+ async populateStoresForHosts() {
+ // Ensure the actor's target is an extension and it is enabled
+ if (!this.addonId || !this.getExtensionPolicy()) {
+ return;
+ }
+
+ // Subscribe a listener for event notifications from the WE storage API when
+ // storage local data has been changed by the extension, and keep track of the
+ // listener to remove it when the debugger is being disconnected.
+ ExtensionStorageIDB.addOnChangedListener(
+ this.addonId,
+ this.onStorageChange
+ );
+
+ try {
+ // Make sure the extension storage APIs have been loaded,
+ // otherwise the DevTools storage panel would not be updated
+ // automatically when the extension storage data is being changed
+ // if the parent ext-storage.js module wasn't already loaded
+ // (See Bug 1802929).
+ const { extension } = WebExtensionPolicy.getByID(this.addonId);
+ await extension.apiManager.asyncGetAPI("storage", extension);
+ // Also watch for addon reload in order to also do that
+ // on next addon startup, otherwise we may also miss updates
+ ExtensionParent.apiManager.on("startup", this.onExtensionStartup);
+ } catch (e) {
+ console.error(
+ "Exception while trying to initialize webext storage API",
+ e
+ );
+ }
+
+ await this.populateStoresForHost(this.extensionHostURL);
+ }
+
+ /**
+ * AddonManager listener used to force instantiating storage API
+ * implementation in the parent process so that it forward content process
+ * messages to ExtensionStorageIDB.
+ *
+ * Without this, we may miss storage updated after the addon reload.
+ */
+ async onExtensionStartup(_evtName, extension) {
+ if (extension.id != this.addonId) {
+ return;
+ }
+ await extension.apiManager.asyncGetAPI("storage", extension);
+ }
+
+ /**
+ * This method asynchronously reads the storage data for the target extension
+ * and caches this data into this.hostVsStores.
+ * @param {String} host - the hostname for the extension
+ */
+ async populateStoresForHost(host) {
+ if (host !== this.extensionHostURL) {
+ return;
+ }
+
+ const extension = ExtensionProcessScript.getExtensionChild(this.addonId);
+ if (!extension || !extension.hasPermission("storage")) {
+ return;
+ }
+
+ // Make sure storeMap is defined and set in this.hostVsStores before subscribing
+ // a storage onChanged listener in the parent process
+ const storeMap = new Map();
+ this.hostVsStores.set(host, storeMap);
+
+ const storagePrincipal = await this.getStoragePrincipal();
+
+ if (!storagePrincipal) {
+ // This could happen if the extension fails to be migrated to the
+ // IndexedDB backend
+ return;
+ }
+
+ const db = await ExtensionStorageIDB.open(storagePrincipal);
+ this.dbConnectionForHost.set(host, db);
+ const data = await db.get();
+
+ for (const [key, value] of Object.entries(data)) {
+ storeMap.set(key, value);
+ }
+
+ if (this.storageActor.parentActor.fallbackWindow) {
+ // Show the storage actor in the add-on storage inspector even when there
+ // is no extension page currently open
+ // This strategy may need to change depending on the outcome of Bug 1597900
+ const storageData = {};
+ storageData[host] = this.getNamesForHost(host);
+ this.storageActor.update("added", this.typeName, storageData);
+ }
+ }
+ /**
+ * This fires when the extension changes storage data while the storage
+ * inspector is open. Ensures this.hostVsStores stays up-to-date and
+ * passes the changes on to update the client.
+ */
+ onStorageChange(changes) {
+ const host = this.extensionHostURL;
+ const storeMap = this.hostVsStores.get(host);
+
+ function isStructuredCloneHolder(value) {
+ return (
+ value &&
+ typeof value === "object" &&
+ Cu.getClassName(value, true) === "StructuredCloneHolder"
+ );
+ }
+
+ for (const key in changes) {
+ const storageChange = changes[key];
+ let { newValue, oldValue } = storageChange;
+ if (isStructuredCloneHolder(newValue)) {
+ newValue = newValue.deserialize(this);
+ }
+ if (isStructuredCloneHolder(oldValue)) {
+ oldValue = oldValue.deserialize(this);
+ }
+
+ let action;
+ if (typeof newValue === "undefined") {
+ action = "deleted";
+ storeMap.delete(key);
+ } else if (typeof oldValue === "undefined") {
+ action = "added";
+ storeMap.set(key, newValue);
+ } else {
+ action = "changed";
+ storeMap.set(key, newValue);
+ }
+
+ this.storageActor.update(action, this.typeName, { [host]: [key] });
+ }
+ }
+
+ async getStoragePrincipal() {
+ const { extension } = this.getExtensionPolicy();
+ const { backendEnabled, storagePrincipal } =
+ await ExtensionStorageIDB.selectBackend({ extension });
+
+ if (!backendEnabled) {
+ // IDB backend disabled; give up.
+ return null;
+ }
+
+ // Received as a StructuredCloneHolder, so we need to deserialize
+ return storagePrincipal.deserialize(this, true);
+ }
+
+ getValuesForHost(host, name) {
+ const result = [];
+
+ if (!this.hostVsStores.has(host)) {
+ return result;
+ }
+
+ if (name) {
+ return [{ name, value: this.hostVsStores.get(host).get(name) }];
+ }
+
+ for (const [key, value] of Array.from(
+ this.hostVsStores.get(host).entries()
+ )) {
+ result.push({ name: key, value });
+ }
+ return result;
+ }
+
+ /**
+ * Converts a storage item to an "extensionobject" as defined in
+ * devtools/shared/specs/storage.js. Behavior largely mirrors the "indexedDB" storage actor,
+ * except where it would throw an unhandled error (i.e. for a `BigInt` or `undefined`
+ * `item.value`).
+ * @param {Object} item - The storage item to convert
+ * @param {String} item.name - The storage item key
+ * @param {*} item.value - The storage item value
+ * @return {extensionobject}
+ */
+ toStoreObject(item) {
+ if (!item) {
+ return null;
+ }
+
+ let { name, value } = item;
+ const isValueEditable = extensionStorageHelpers.isEditable(value);
+
+ // `JSON.stringify()` throws for `BigInt`, adds extra quotes to strings and `Date` strings,
+ // and doesn't modify `undefined`.
+ switch (typeof value) {
+ case "bigint":
+ value = `${value.toString()}n`;
+ break;
+ case "string":
+ break;
+ case "undefined":
+ value = "undefined";
+ break;
+ default:
+ value = JSON.stringify(value);
+ if (
+ // can't use `instanceof` across frame boundaries
+ Object.prototype.toString.call(item.value) === "[object Date]"
+ ) {
+ value = JSON.parse(value);
+ }
+ }
+
+ return {
+ name,
+ value: new LongStringActor(this.conn, value),
+ area: "local", // Bug 1542038, 1542039: set the correct storage area
+ isValueEditable,
+ };
+ }
+
+ getFields() {
+ return [
+ { name: "name", editable: false },
+ { name: "value", editable: true },
+ { name: "area", editable: false },
+ { name: "isValueEditable", editable: false, private: true },
+ ];
+ }
+
+ onItemUpdated(action, host, names) {
+ this.storageActor.update(action, this.typeName, {
+ [host]: names,
+ });
+ }
+
+ async editItem({ host, field, items, oldValue }) {
+ const db = this.dbConnectionForHost.get(host);
+ if (!db) {
+ return;
+ }
+
+ const { name, value } = items;
+
+ let parsedValue = parseItemValue(value);
+ if (parsedValue === value) {
+ const { typesFromString } = extensionStorageHelpers;
+ for (const { test, parse } of Object.values(typesFromString)) {
+ if (test(value)) {
+ parsedValue = parse(value);
+ break;
+ }
+ }
+ }
+ const changes = await db.set({ [name]: parsedValue });
+ this.fireOnChangedExtensionEvent(host, changes);
+
+ this.onItemUpdated("changed", host, [name]);
+ }
+
+ async removeItem(host, name) {
+ const db = this.dbConnectionForHost.get(host);
+ if (!db) {
+ return;
+ }
+
+ const changes = await db.remove(name);
+ this.fireOnChangedExtensionEvent(host, changes);
+
+ this.onItemUpdated("deleted", host, [name]);
+ }
+
+ async removeAll(host) {
+ const db = this.dbConnectionForHost.get(host);
+ if (!db) {
+ return;
+ }
+
+ const changes = await db.clear();
+ this.fireOnChangedExtensionEvent(host, changes);
+
+ this.onItemUpdated("cleared", host, []);
+ }
+
+ /**
+ * Let the extension know that storage data has been changed by the user from
+ * the storage inspector.
+ */
+ fireOnChangedExtensionEvent(host, changes) {
+ // Bug 1542038, 1542039: Which message to send depends on the storage area
+ const uuid = new URL(host).host;
+ Services.cpmm.sendAsyncMessage(
+ `Extension:StorageLocalOnChanged:${uuid}`,
+ changes
+ );
+ }
+}
+exports.ExtensionStorageActor = ExtensionStorageActor;
+
+const extensionStorageHelpers = {
+ /**
+ * Editing is supported only for serializable types. Examples of unserializable
+ * types include Map, Set and ArrayBuffer.
+ */
+ isEditable(value) {
+ // Bug 1542038: the managed storage area is never editable
+ for (const { test } of Object.values(this.supportedTypes)) {
+ if (test(value)) {
+ return true;
+ }
+ }
+ return false;
+ },
+ isPrimitive(value) {
+ const primitiveValueTypes = ["string", "number", "boolean"];
+ return primitiveValueTypes.includes(typeof value) || value === null;
+ },
+ isObjectLiteral(value) {
+ return (
+ value &&
+ typeof value === "object" &&
+ Cu.getClassName(value, true) === "Object"
+ );
+ },
+ // Nested arrays or object literals are only editable 2 levels deep
+ isArrayOrObjectLiteralEditable(obj) {
+ const topLevelValuesArr = Array.isArray(obj) ? obj : Object.values(obj);
+ if (
+ topLevelValuesArr.some(
+ value =>
+ !this.isPrimitive(value) &&
+ !Array.isArray(value) &&
+ !this.isObjectLiteral(value)
+ )
+ ) {
+ // At least one value is too complex to parse
+ return false;
+ }
+ const arrayOrObjects = topLevelValuesArr.filter(
+ value => Array.isArray(value) || this.isObjectLiteral(value)
+ );
+ if (arrayOrObjects.length === 0) {
+ // All top level values are primitives
+ return true;
+ }
+
+ // One or more top level values was an array or object literal.
+ // All of these top level values must themselves have only primitive values
+ // for the object to be editable
+ for (const nestedObj of arrayOrObjects) {
+ const secondLevelValuesArr = Array.isArray(nestedObj)
+ ? nestedObj
+ : Object.values(nestedObj);
+ if (secondLevelValuesArr.some(value => !this.isPrimitive(value))) {
+ return false;
+ }
+ }
+ return true;
+ },
+ typesFromString: {
+ // Helper methods to parse string values in editItem
+ jsonifiable: {
+ test(str) {
+ try {
+ JSON.parse(str);
+ } catch (e) {
+ return false;
+ }
+ return true;
+ },
+ parse(str) {
+ return JSON.parse(str);
+ },
+ },
+ },
+ supportedTypes: {
+ // Helper methods to determine the value type of an item in isEditable
+ array: {
+ test(value) {
+ if (Array.isArray(value)) {
+ return extensionStorageHelpers.isArrayOrObjectLiteralEditable(value);
+ }
+ return false;
+ },
+ },
+ boolean: {
+ test(value) {
+ return typeof value === "boolean";
+ },
+ },
+ null: {
+ test(value) {
+ return value === null;
+ },
+ },
+ number: {
+ test(value) {
+ return typeof value === "number";
+ },
+ },
+ object: {
+ test(value) {
+ if (extensionStorageHelpers.isObjectLiteral(value)) {
+ return extensionStorageHelpers.isArrayOrObjectLiteralEditable(value);
+ }
+ return false;
+ },
+ },
+ string: {
+ test(value) {
+ return typeof value === "string";
+ },
+ },
+ },
+};
diff --git a/devtools/server/actors/resources/storage/index.js b/devtools/server/actors/resources/storage/index.js
new file mode 100644
index 0000000000..147f9056ea
--- /dev/null
+++ b/devtools/server/actors/resources/storage/index.js
@@ -0,0 +1,404 @@
+/* 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 { Actor } = require("resource://devtools/shared/protocol.js");
+const specs = require("resource://devtools/shared/specs/storage.js");
+
+loader.lazyRequireGetter(
+ this,
+ "naturalSortCaseInsensitive",
+ "resource://devtools/shared/natural-sort.js",
+ true
+);
+
+// Maximum number of cookies/local storage key-value-pairs that can be sent
+// over the wire to the client in one request.
+const MAX_STORE_OBJECT_COUNT = 50;
+exports.MAX_STORE_OBJECT_COUNT = MAX_STORE_OBJECT_COUNT;
+
+const DEFAULT_VALUE = "value";
+exports.DEFAULT_VALUE = DEFAULT_VALUE;
+
+// GUID to be used as a separator in compound keys. This must match the same
+// constant in devtools/client/storage/ui.js,
+// devtools/client/storage/test/head.js and
+// devtools/server/tests/browser/head.js
+const SEPARATOR_GUID = "{9d414cc5-8319-0a04-0586-c0a6ae01670a}";
+exports.SEPARATOR_GUID = SEPARATOR_GUID;
+
+class BaseStorageActor extends Actor {
+ /**
+ * Base class with the common methods required by all storage actors.
+ *
+ * This base class is missing a couple of required methods that should be
+ * implemented seperately for each actor. They are namely:
+ * - observe : Method which gets triggered on the notification of the watched
+ * topic.
+ * - getNamesForHost : Given a host, get list of all known store names.
+ * - getValuesForHost : Given a host (and optionally a name) get all known
+ * store objects.
+ * - toStoreObject : Given a store object, convert it to the required format
+ * so that it can be transferred over wire.
+ * - populateStoresForHost : Given a host, populate the map of all store
+ * objects for it
+ * - getFields: Given a subType(optional), get an array of objects containing
+ * column field info. The info includes,
+ * "name" is name of colume key.
+ * "editable" is 1 means editable field; 0 means uneditable.
+ *
+ * @param {string} typeName
+ * The typeName of the actor.
+ */
+ constructor(storageActor, typeName) {
+ super(storageActor.conn, specs.childSpecs[typeName]);
+
+ this.storageActor = storageActor;
+
+ // Map keyed by host name whose values are nested Maps.
+ // Nested maps are keyed by store names and values are store values.
+ // Store values are specific to each sub class.
+ // Map(host name => stores <Map(name => values )>)
+ // Map(string => stores <Map(string => any )>)
+ this.hostVsStores = new Map();
+
+ this.onWindowReady = this.onWindowReady.bind(this);
+ this.onWindowDestroyed = this.onWindowDestroyed.bind(this);
+ this.storageActor.on("window-ready", this.onWindowReady);
+ this.storageActor.on("window-destroyed", this.onWindowDestroyed);
+ }
+
+ destroy() {
+ if (!this.storageActor) {
+ return;
+ }
+
+ this.storageActor.off("window-ready", this.onWindowReady);
+ this.storageActor.off("window-destroyed", this.onWindowDestroyed);
+
+ this.hostVsStores.clear();
+
+ super.destroy();
+
+ this.storageActor = null;
+ }
+
+ /**
+ * Returns a list of currently known hosts for the target window. This list
+ * contains unique hosts from the window + all inner windows. If
+ * this._internalHosts is defined then these will also be added to the list.
+ */
+ get hosts() {
+ const hosts = new Set();
+ for (const { location } of this.storageActor.windows) {
+ const host = this.getHostName(location);
+
+ if (host) {
+ hosts.add(host);
+ }
+ }
+ if (this._internalHosts) {
+ for (const host of this._internalHosts) {
+ hosts.add(host);
+ }
+ }
+ return hosts;
+ }
+
+ /**
+ * Returns all the windows present on the page. Includes main window + inner
+ * iframe windows.
+ */
+ get windows() {
+ return this.storageActor.windows;
+ }
+
+ /**
+ * Converts the window.location object into a URL (e.g. http://domain.com).
+ */
+ getHostName(location) {
+ if (!location) {
+ // Debugging a legacy Firefox extension... no hostname available and no
+ // storage possible.
+ return null;
+ }
+
+ if (this.storageActor.getHostName) {
+ return this.storageActor.getHostName(location);
+ }
+
+ switch (location.protocol) {
+ case "about:":
+ return `${location.protocol}${location.pathname}`;
+ case "chrome:":
+ // chrome: URLs do not support storage of any type.
+ return null;
+ case "data:":
+ // data: URLs do not support storage of any type.
+ return null;
+ case "file:":
+ return `${location.protocol}//${location.pathname}`;
+ case "javascript:":
+ return location.href;
+ case "moz-extension:":
+ return location.origin;
+ case "resource:":
+ return `${location.origin}${location.pathname}`;
+ default:
+ // http: or unknown protocol.
+ return `${location.protocol}//${location.host}`;
+ }
+ }
+
+ /**
+ * Populates a map of known hosts vs a map of stores vs value.
+ */
+ async populateStoresForHosts() {
+ for (const host of this.hosts) {
+ await this.populateStoresForHost(host);
+ }
+ }
+
+ getNamesForHost(host) {
+ return [...this.hostVsStores.get(host).keys()];
+ }
+
+ getValuesForHost(host, name) {
+ if (name) {
+ return [this.hostVsStores.get(host).get(name)];
+ }
+ return [...this.hostVsStores.get(host).values()];
+ }
+
+ getObjectsSize(host, names) {
+ return names.length;
+ }
+
+ /**
+ * When a new window is added to the page. This generally means that a new
+ * iframe is created, or the current window is completely reloaded.
+ *
+ * @param {window} window
+ * The window which was added.
+ */
+ async onWindowReady(window) {
+ if (!this.hostVsStores) {
+ return;
+ }
+ const host = this.getHostName(window.location);
+ if (host && !this.hostVsStores.has(host)) {
+ await this.populateStoresForHost(host, window);
+ if (!this.storageActor) {
+ // The actor might be destroyed during populateStoresForHost.
+ return;
+ }
+
+ const data = {};
+ data[host] = this.getNamesForHost(host);
+ this.storageActor.update("added", this.typeName, data);
+ }
+ }
+
+ /**
+ * When a window is removed from the page. This generally means that an
+ * iframe was removed, or the current window reload is triggered.
+ *
+ * @param {window} window
+ * The window which was removed.
+ * @param {Object} options
+ * @param {Boolean} options.dontCheckHost
+ * If set to true, the function won't check if the host still is in this.hosts.
+ * This is helpful in the case of the StorageActorMock, as the `hosts` getter
+ * uses its `windows` getter, and at this point in time the window which is
+ * going to be destroyed still exists.
+ */
+ onWindowDestroyed(window, { dontCheckHost } = {}) {
+ if (!this.hostVsStores) {
+ return;
+ }
+ if (!window.location) {
+ // Nothing can be done if location object is null
+ return;
+ }
+ const host = this.getHostName(window.location);
+ if (host && (!this.hosts.has(host) || dontCheckHost)) {
+ this.hostVsStores.delete(host);
+ const data = {};
+ data[host] = [];
+ this.storageActor.update("deleted", this.typeName, data);
+ }
+ }
+
+ form() {
+ const hosts = {};
+ for (const host of this.hosts) {
+ hosts[host] = [];
+ }
+
+ return {
+ actor: this.actorID,
+ hosts,
+ traits: this._getTraits(),
+ };
+ }
+
+ // Share getTraits for child classes overriding form()
+ _getTraits() {
+ return {
+ // The supportsXXX traits are not related to backward compatibility
+ // Different storage actor types implement different APIs, the traits
+ // help the client to know what is supported or not.
+ supportsAddItem: typeof this.addItem === "function",
+ // Note: supportsRemoveItem and supportsRemoveAll are always defined
+ // for all actors. See Bug 1655001.
+ supportsRemoveItem: typeof this.removeItem === "function",
+ supportsRemoveAll: typeof this.removeAll === "function",
+ supportsRemoveAllSessionCookies:
+ typeof this.removeAllSessionCookies === "function",
+ };
+ }
+
+ /**
+ * Returns a list of requested store objects. Maximum values returned are
+ * MAX_STORE_OBJECT_COUNT. This method returns paginated values whose
+ * starting index and total size can be controlled via the options object
+ *
+ * @param {string} host
+ * The host name for which the store values are required.
+ * @param {array:string} names
+ * Array containing the names of required store objects. Empty if all
+ * items are required.
+ * @param {object} options
+ * Additional options for the request containing following
+ * properties:
+ * - offset {number} : The begin index of the returned array amongst
+ * the total values
+ * - size {number} : The number of values required.
+ * - sortOn {string} : The values should be sorted on this property.
+ * - index {string} : In case of indexed db, the IDBIndex to be used
+ * for fetching the values.
+ * - sessionString {string} : Client-side value of storage-expires-session
+ * l10n string. Since this function can be called from both
+ * the client and the server, and given that client and
+ * server might have different locales, we can't compute
+ * the localized string directly from here.
+ * @return {object} An object containing following properties:
+ * - offset - The actual offset of the returned array. This might
+ * be different from the requested offset if that was
+ * invalid
+ * - total - The total number of entries possible.
+ * - data - The requested values.
+ */
+ async getStoreObjects(host, names, options = {}) {
+ const offset = options.offset || 0;
+ let size = options.size || MAX_STORE_OBJECT_COUNT;
+ if (size > MAX_STORE_OBJECT_COUNT) {
+ size = MAX_STORE_OBJECT_COUNT;
+ }
+ const sortOn = options.sortOn || "name";
+
+ const toReturn = {
+ offset,
+ total: 0,
+ data: [],
+ };
+
+ let principal = null;
+ if (this.typeName === "indexedDB") {
+ // We only acquire principal when the type of the storage is indexedDB
+ // because the principal only matters the indexedDB.
+ const win = this.storageActor.getWindowFromHost(host);
+ principal = this.getPrincipal(win);
+ }
+
+ if (names) {
+ for (const name of names) {
+ const values = await this.getValuesForHost(
+ host,
+ name,
+ options,
+ this.hostVsStores,
+ principal
+ );
+
+ const { result, objectStores } = values;
+
+ if (result && typeof result.objectsSize !== "undefined") {
+ for (const { key, count } of result.objectsSize) {
+ this.objectsSize[key] = count;
+ }
+ }
+
+ if (result) {
+ toReturn.data.push(...result.data);
+ } else if (objectStores) {
+ toReturn.data.push(...objectStores);
+ } else {
+ toReturn.data.push(...values);
+ }
+ }
+
+ if (this.typeName === "Cache") {
+ // Cache storage contains several items per name but misses a custom
+ // `getObjectsSize` implementation, as implemented for IndexedDB.
+ // See Bug 1745242.
+ toReturn.total = toReturn.data.length;
+ } else {
+ toReturn.total = this.getObjectsSize(host, names, options);
+ }
+ } else {
+ let obj = await this.getValuesForHost(
+ host,
+ undefined,
+ undefined,
+ this.hostVsStores,
+ principal
+ );
+ if (obj.dbs) {
+ obj = obj.dbs;
+ }
+
+ toReturn.total = obj.length;
+ toReturn.data = obj;
+ }
+
+ if (offset > toReturn.total) {
+ // In this case, toReturn.data is an empty array.
+ toReturn.offset = toReturn.total;
+ toReturn.data = [];
+ } else {
+ // We need to use natural sort before slicing.
+ const sorted = toReturn.data.sort((a, b) => {
+ return naturalSortCaseInsensitive(
+ a[sortOn],
+ b[sortOn],
+ options.sessionString
+ );
+ });
+ let sliced;
+ if (this.typeName === "indexedDB") {
+ // indexedDB's getValuesForHost never returns *all* values available but only
+ // a slice, starting at the expected offset. Therefore the result is already
+ // sliced as expected.
+ sliced = sorted;
+ } else {
+ sliced = sorted.slice(offset, offset + size);
+ }
+ toReturn.data = sliced.map(a => this.toStoreObject(a));
+ }
+
+ return toReturn;
+ }
+
+ getPrincipal(win) {
+ if (win) {
+ return win.document.effectiveStoragePrincipal;
+ }
+ // We are running in the browser toolbox and viewing system DBs so we
+ // need to use system principal.
+ return Cc["@mozilla.org/systemprincipal;1"].createInstance(Ci.nsIPrincipal);
+ }
+}
+exports.BaseStorageActor = BaseStorageActor;
diff --git a/devtools/server/actors/resources/storage/indexed-db.js b/devtools/server/actors/resources/storage/indexed-db.js
new file mode 100644
index 0000000000..8ded705c4f
--- /dev/null
+++ b/devtools/server/actors/resources/storage/indexed-db.js
@@ -0,0 +1,984 @@
+/* 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 {
+ BaseStorageActor,
+ MAX_STORE_OBJECT_COUNT,
+ SEPARATOR_GUID,
+} = require("resource://devtools/server/actors/resources/storage/index.js");
+const {
+ LongStringActor,
+} = require("resource://devtools/server/actors/string.js");
+// We give this a funny name to avoid confusion with the global
+// indexedDB.
+loader.lazyGetter(this, "indexedDBForStorage", () => {
+ // On xpcshell, we can't instantiate indexedDB without crashing
+ try {
+ const sandbox = Cu.Sandbox(
+ Components.Constructor(
+ "@mozilla.org/systemprincipal;1",
+ "nsIPrincipal"
+ )(),
+ { wantGlobalProperties: ["indexedDB"] }
+ );
+ return sandbox.indexedDB;
+ } catch (e) {
+ return {};
+ }
+});
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ Sqlite: "resource://gre/modules/Sqlite.sys.mjs",
+});
+
+/**
+ * An async method equivalent to setTimeout but using Promises
+ *
+ * @param {number} time
+ * The wait time in milliseconds.
+ */
+function sleep(time) {
+ return new Promise(resolve => {
+ setTimeout(() => {
+ resolve(null);
+ }, time);
+ });
+}
+
+const SAFE_HOSTS_PREFIXES_REGEX = /^(about\+|https?\+|file\+|moz-extension\+)/;
+
+// A RegExp for characters that cannot appear in a file/directory name. This is
+// used to sanitize the host name for indexed db to lookup whether the file is
+// present in <profileDir>/storage/default/ location
+const illegalFileNameCharacters = [
+ "[",
+ // Control characters \001 to \036
+ "\\x00-\\x24",
+ // Special characters
+ '/:*?\\"<>|\\\\',
+ "]",
+].join("");
+const ILLEGAL_CHAR_REGEX = new RegExp(illegalFileNameCharacters, "g");
+
+/**
+ * Code related to the Indexed DB actor and front
+ */
+
+// Metadata holder objects for various components of Indexed DB
+
+/**
+ * Meta data object for a particular index in an object store
+ *
+ * @param {IDBIndex} index
+ * The particular index from the object store.
+ */
+function IndexMetadata(index) {
+ this._name = index.name;
+ this._keyPath = index.keyPath;
+ this._unique = index.unique;
+ this._multiEntry = index.multiEntry;
+}
+IndexMetadata.prototype = {
+ toObject() {
+ return {
+ name: this._name,
+ keyPath: this._keyPath,
+ unique: this._unique,
+ multiEntry: this._multiEntry,
+ };
+ },
+};
+
+/**
+ * Meta data object for a particular object store in a db
+ *
+ * @param {IDBObjectStore} objectStore
+ * The particular object store from the db.
+ */
+function ObjectStoreMetadata(objectStore) {
+ this._name = objectStore.name;
+ this._keyPath = objectStore.keyPath;
+ this._autoIncrement = objectStore.autoIncrement;
+ this._indexes = [];
+
+ for (let i = 0; i < objectStore.indexNames.length; i++) {
+ const index = objectStore.index(objectStore.indexNames[i]);
+
+ const newIndex = {
+ keypath: index.keyPath,
+ multiEntry: index.multiEntry,
+ name: index.name,
+ objectStore: {
+ autoIncrement: index.objectStore.autoIncrement,
+ indexNames: [...index.objectStore.indexNames],
+ keyPath: index.objectStore.keyPath,
+ name: index.objectStore.name,
+ },
+ };
+
+ this._indexes.push([newIndex, new IndexMetadata(index)]);
+ }
+}
+ObjectStoreMetadata.prototype = {
+ toObject() {
+ return {
+ name: this._name,
+ keyPath: this._keyPath,
+ autoIncrement: this._autoIncrement,
+ indexes: JSON.stringify(
+ [...this._indexes.values()].map(index => index.toObject())
+ ),
+ };
+ },
+};
+
+/**
+ * Meta data object for a particular indexed db in a host.
+ *
+ * @param {string} origin
+ * The host associated with this indexed db.
+ * @param {IDBDatabase} db
+ * The particular indexed db.
+ * @param {String} storage
+ * Storage type, either "temporary", "default" or "persistent".
+ */
+function DatabaseMetadata(origin, db, storage) {
+ this._origin = origin;
+ this._name = db.name;
+ this._version = db.version;
+ this._objectStores = [];
+ this.storage = storage;
+
+ if (db.objectStoreNames.length) {
+ const transaction = db.transaction(db.objectStoreNames, "readonly");
+
+ for (let i = 0; i < transaction.objectStoreNames.length; i++) {
+ const objectStore = transaction.objectStore(
+ transaction.objectStoreNames[i]
+ );
+ this._objectStores.push([
+ transaction.objectStoreNames[i],
+ new ObjectStoreMetadata(objectStore),
+ ]);
+ }
+ }
+}
+DatabaseMetadata.prototype = {
+ get objectStores() {
+ return this._objectStores;
+ },
+
+ toObject() {
+ return {
+ uniqueKey: `${this._name}${SEPARATOR_GUID}${this.storage}`,
+ name: this._name,
+ storage: this.storage,
+ origin: this._origin,
+ version: this._version,
+ objectStores: this._objectStores.size,
+ };
+ },
+};
+
+class IndexedDBStorageActor extends BaseStorageActor {
+ constructor(storageActor) {
+ super(storageActor, "indexedDB");
+
+ this.objectsSize = {};
+ this.storageActor = storageActor;
+ }
+
+ destroy() {
+ this.objectsSize = null;
+
+ super.destroy();
+ }
+
+ // We need to override this method because of custom, async getHosts method
+ async populateStoresForHosts() {
+ for (const host of await this.getHosts()) {
+ await this.populateStoresForHost(host);
+ }
+ }
+
+ async populateStoresForHost(host) {
+ const storeMap = new Map();
+
+ const win = this.storageActor.getWindowFromHost(host);
+ const principal = this.getPrincipal(win);
+
+ const { names } = await this.getDBNamesForHost(host, principal);
+
+ for (const { name, storage } of names) {
+ let metadata = await this.getDBMetaData(host, principal, name, storage);
+
+ metadata = this.patchMetadataMapsAndProtos(metadata);
+
+ storeMap.set(`${name} (${storage})`, metadata);
+ }
+
+ this.hostVsStores.set(host, storeMap);
+ }
+
+ /**
+ * Returns a list of currently known hosts for the target window. This list
+ * contains unique hosts from the window, all inner windows and all permanent
+ * indexedDB hosts defined inside the browser.
+ */
+ async getHosts() {
+ // Add internal hosts to this._internalHosts, which will be picked up by
+ // the this.hosts getter. Because this.hosts is a property on the default
+ // storage actor and inherited by all storage actors we have to do it this
+ // way.
+ // Only look up internal hosts if we are in the browser toolbox
+ const isBrowserToolbox = this.storageActor.parentActor.isRootActor;
+
+ this._internalHosts = isBrowserToolbox ? await this.getInternalHosts() : [];
+
+ return this.hosts;
+ }
+
+ /**
+ * Remove an indexedDB database from given host with a given name.
+ */
+ async removeDatabase(host, name) {
+ const win = this.storageActor.getWindowFromHost(host);
+ if (!win) {
+ return { error: `Window for host ${host} not found` };
+ }
+
+ const principal = win.document.effectiveStoragePrincipal;
+ return this.removeDB(host, principal, name);
+ }
+
+ async removeAll(host, name) {
+ const [db, store] = JSON.parse(name);
+
+ const win = this.storageActor.getWindowFromHost(host);
+ if (!win) {
+ return;
+ }
+
+ const principal = win.document.effectiveStoragePrincipal;
+ this.clearDBStore(host, principal, db, store);
+ }
+
+ async removeItem(host, name) {
+ const [db, store, id] = JSON.parse(name);
+
+ const win = this.storageActor.getWindowFromHost(host);
+ if (!win) {
+ return;
+ }
+
+ const principal = win.document.effectiveStoragePrincipal;
+ this.removeDBRecord(host, principal, db, store, id);
+ }
+
+ getNamesForHost(host) {
+ const storesForHost = this.hostVsStores.get(host);
+ if (!storesForHost) {
+ return [];
+ }
+
+ const names = [];
+
+ for (const [dbName, { objectStores }] of storesForHost) {
+ if (objectStores.size) {
+ for (const objectStore of objectStores.keys()) {
+ names.push(JSON.stringify([dbName, objectStore]));
+ }
+ } else {
+ names.push(JSON.stringify([dbName]));
+ }
+ }
+
+ return names;
+ }
+
+ /**
+ * Returns the total number of entries for various types of requests to
+ * getStoreObjects for Indexed DB actor.
+ *
+ * @param {string} host
+ * The host for the request.
+ * @param {array:string} names
+ * Array of stringified name objects for indexed db actor.
+ * The request type depends on the length of any parsed entry from this
+ * array. 0 length refers to request for the whole host. 1 length
+ * refers to request for a particular db in the host. 2 length refers
+ * to a particular object store in a db in a host. 3 length refers to
+ * particular items of an object store in a db in a host.
+ * @param {object} options
+ * An options object containing following properties:
+ * - index {string} The IDBIndex for the object store in the db.
+ */
+ getObjectsSize(host, names, options) {
+ // In Indexed DB, we are interested in only the first name, as the pattern
+ // should follow in all entries.
+ const name = names[0];
+ const parsedName = JSON.parse(name);
+
+ if (parsedName.length == 3) {
+ // This is the case where specific entries from an object store were
+ // requested
+ return names.length;
+ } else if (parsedName.length == 2) {
+ // This is the case where all entries from an object store are requested.
+ const index = options.index;
+ const [db, objectStore] = parsedName;
+ if (this.objectsSize[host + db + objectStore + index]) {
+ return this.objectsSize[host + db + objectStore + index];
+ }
+ } else if (parsedName.length == 1) {
+ // This is the case where details of all object stores in a db are
+ // requested.
+ if (
+ this.hostVsStores.has(host) &&
+ this.hostVsStores.get(host).has(parsedName[0])
+ ) {
+ return this.hostVsStores.get(host).get(parsedName[0]).objectStores.size;
+ }
+ } else if (!parsedName || !parsedName.length) {
+ // This is the case were details of all dbs in a host are requested.
+ if (this.hostVsStores.has(host)) {
+ return this.hostVsStores.get(host).size;
+ }
+ }
+ return 0;
+ }
+
+ /**
+ * Returns the over-the-wire implementation of the indexed db entity.
+ */
+ toStoreObject(item) {
+ if (!item) {
+ return null;
+ }
+
+ if ("indexes" in item) {
+ // Object store meta data
+ return {
+ objectStore: item.name,
+ keyPath: item.keyPath,
+ autoIncrement: item.autoIncrement,
+ indexes: item.indexes,
+ };
+ }
+ if ("objectStores" in item) {
+ // DB meta data
+ return {
+ uniqueKey: `${item.name} (${item.storage})`,
+ db: item.name,
+ storage: item.storage,
+ origin: item.origin,
+ version: item.version,
+ objectStores: item.objectStores,
+ };
+ }
+
+ const value = JSON.stringify(item.value);
+
+ // Indexed db entry
+ return {
+ name: item.name,
+ value: new LongStringActor(this.conn, value),
+ };
+ }
+
+ form() {
+ const hosts = {};
+ for (const host of this.hosts) {
+ hosts[host] = this.getNamesForHost(host);
+ }
+
+ return {
+ actor: this.actorID,
+ hosts,
+ traits: this._getTraits(),
+ };
+ }
+
+ onItemUpdated(action, host, path) {
+ dump(" IDX.onItemUpdated(" + action + " - " + host + " - " + path + "\n");
+ // Database was removed, remove it from stores map
+ if (action === "deleted" && path.length === 1) {
+ if (this.hostVsStores.has(host)) {
+ this.hostVsStores.get(host).delete(path[0]);
+ }
+ }
+
+ this.storageActor.update(action, "indexedDB", {
+ [host]: [JSON.stringify(path)],
+ });
+ }
+
+ async getFields(subType) {
+ switch (subType) {
+ // Detail of database
+ case "database":
+ return [
+ { name: "objectStore", editable: false },
+ { name: "keyPath", editable: false },
+ { name: "autoIncrement", editable: false },
+ { name: "indexes", editable: false },
+ ];
+
+ // Detail of object store
+ case "object store":
+ return [
+ { name: "name", editable: false },
+ { name: "value", editable: false },
+ ];
+
+ // Detail of indexedDB for one origin
+ default:
+ return [
+ { name: "uniqueKey", editable: false, private: true },
+ { name: "db", editable: false },
+ { name: "storage", editable: false },
+ { name: "origin", editable: false },
+ { name: "version", editable: false },
+ { name: "objectStores", editable: false },
+ ];
+ }
+ }
+
+ /**
+ * Fetches and stores all the metadata information for the given database
+ * `name` for the given `host` with its `principal`. The stored metadata
+ * information is of `DatabaseMetadata` type.
+ */
+ async getDBMetaData(host, principal, name, storage) {
+ const request = this.openWithPrincipal(principal, name, storage);
+ return new Promise(resolve => {
+ request.onsuccess = event => {
+ const db = event.target.result;
+ const dbData = new DatabaseMetadata(host, db, storage);
+ db.close();
+
+ resolve(dbData);
+ };
+ request.onerror = ({ target }) => {
+ console.error(
+ `Error opening indexeddb database ${name} for host ${host}`,
+ target.error
+ );
+ resolve(null);
+ };
+ });
+ }
+
+ splitNameAndStorage(name) {
+ const lastOpenBracketIndex = name.lastIndexOf("(");
+ const lastCloseBracketIndex = name.lastIndexOf(")");
+ const delta = lastCloseBracketIndex - lastOpenBracketIndex - 1;
+
+ const storage = name.substr(lastOpenBracketIndex + 1, delta);
+
+ name = name.substr(0, lastOpenBracketIndex - 1);
+
+ return { storage, name };
+ }
+
+ /**
+ * Get all "internal" hosts. Internal hosts are database namespaces used by
+ * the browser.
+ */
+ async getInternalHosts() {
+ const profileDir = PathUtils.profileDir;
+ const storagePath = PathUtils.join(profileDir, "storage", "permanent");
+ const children = await IOUtils.getChildren(storagePath);
+ const hosts = [];
+
+ for (const path of children) {
+ const exists = await IOUtils.exists(path);
+ if (!exists) {
+ continue;
+ }
+
+ const stats = await IOUtils.stat(path);
+ if (
+ stats.type === "directory" &&
+ !SAFE_HOSTS_PREFIXES_REGEX.test(stats.path)
+ ) {
+ const basename = PathUtils.filename(path);
+ hosts.push(basename);
+ }
+ }
+
+ return hosts;
+ }
+
+ /**
+ * Opens an indexed db connection for the given `principal` and
+ * database `name`.
+ */
+ openWithPrincipal(principal, name, storage) {
+ return indexedDBForStorage.openForPrincipal(principal, name, {
+ storage,
+ });
+ }
+
+ async removeDB(host, principal, dbName) {
+ const result = new Promise(resolve => {
+ const { name, storage } = this.splitNameAndStorage(dbName);
+ const request = indexedDBForStorage.deleteForPrincipal(principal, name, {
+ storage,
+ });
+
+ request.onsuccess = () => {
+ resolve({});
+ this.onItemUpdated("deleted", host, [dbName]);
+ };
+
+ request.onblocked = () => {
+ console.warn(
+ `Deleting indexedDB database ${name} for host ${host} is blocked`
+ );
+ resolve({ blocked: true });
+ };
+
+ request.onerror = () => {
+ const { error } = request;
+ console.warn(
+ `Error deleting indexedDB database ${name} for host ${host}: ${error}`
+ );
+ resolve({ error: error.message });
+ };
+
+ // If the database is blocked repeatedly, the onblocked event will not
+ // be fired again. To avoid waiting forever, report as blocked if nothing
+ // else happens after 3 seconds.
+ setTimeout(() => resolve({ blocked: true }), 3000);
+ });
+
+ return result;
+ }
+
+ async removeDBRecord(host, principal, dbName, storeName, id) {
+ let db;
+ const { name, storage } = this.splitNameAndStorage(dbName);
+
+ try {
+ db = await new Promise((resolve, reject) => {
+ const request = this.openWithPrincipal(principal, name, storage);
+ request.onsuccess = ev => resolve(ev.target.result);
+ request.onerror = ev => reject(ev.target.error);
+ });
+
+ const transaction = db.transaction(storeName, "readwrite");
+ const store = transaction.objectStore(storeName);
+
+ await new Promise((resolve, reject) => {
+ const request = store.delete(id);
+ request.onsuccess = () => resolve();
+ request.onerror = ev => reject(ev.target.error);
+ });
+
+ this.onItemUpdated("deleted", host, [dbName, storeName, id]);
+ } catch (error) {
+ const recordPath = [dbName, storeName, id].join("/");
+ console.error(
+ `Failed to delete indexedDB record: ${recordPath}: ${error}`
+ );
+ }
+
+ if (db) {
+ db.close();
+ }
+
+ return null;
+ }
+
+ async clearDBStore(host, principal, dbName, storeName) {
+ let db;
+ const { name, storage } = this.splitNameAndStorage(dbName);
+
+ try {
+ db = await new Promise((resolve, reject) => {
+ const request = this.openWithPrincipal(principal, name, storage);
+ request.onsuccess = ev => resolve(ev.target.result);
+ request.onerror = ev => reject(ev.target.error);
+ });
+
+ const transaction = db.transaction(storeName, "readwrite");
+ const store = transaction.objectStore(storeName);
+
+ await new Promise((resolve, reject) => {
+ const request = store.clear();
+ request.onsuccess = () => resolve();
+ request.onerror = ev => reject(ev.target.error);
+ });
+
+ this.onItemUpdated("cleared", host, [dbName, storeName]);
+ } catch (error) {
+ const storePath = [dbName, storeName].join("/");
+ console.error(`Failed to clear indexedDB store: ${storePath}: ${error}`);
+ }
+
+ if (db) {
+ db.close();
+ }
+
+ return null;
+ }
+
+ /**
+ * Fetches all the databases and their metadata for the given `host`.
+ */
+ async getDBNamesForHost(host, principal) {
+ const sanitizedHost = this.getSanitizedHost(host) + principal.originSuffix;
+ const profileDir = PathUtils.profileDir;
+ const storagePath = PathUtils.join(profileDir, "storage");
+ const files = [];
+ const names = [];
+
+ // We expect sqlite DB paths to look something like this:
+ // - PathToProfileDir/storage/default/http+++www.example.com/
+ // idb/1556056096MeysDaabta.sqlite
+ // - PathToProfileDir/storage/permanent/http+++www.example.com/
+ // idb/1556056096MeysDaabta.sqlite
+ // - PathToProfileDir/storage/temporary/http+++www.example.com/
+ // idb/1556056096MeysDaabta.sqlite
+ // The subdirectory inside the storage folder is determined by the storage
+ // type:
+ // - default: { storage: "default" } or not specified.
+ // - permanent: { storage: "persistent" }.
+ // - temporary: { storage: "temporary" }.
+ const sqliteFiles = await this.findSqlitePathsForHost(
+ storagePath,
+ sanitizedHost
+ );
+
+ for (const file of sqliteFiles) {
+ const splitPath = PathUtils.split(file);
+ const idbIndex = splitPath.indexOf("idb");
+ const storage = splitPath[idbIndex - 2];
+ const relative = file.substr(profileDir.length + 1);
+
+ files.push({
+ file: relative,
+ storage: storage === "permanent" ? "persistent" : storage,
+ });
+ }
+
+ if (files.length) {
+ for (const { file, storage } of files) {
+ const name = await this.getNameFromDatabaseFile(file);
+ if (name) {
+ names.push({
+ name,
+ storage,
+ });
+ }
+ }
+ }
+
+ return { names };
+ }
+
+ /**
+ * Find all SQLite files that hold IndexedDB data for a host, such as:
+ * storage/temporary/http+++www.example.com/idb/1556056096MeysDaabta.sqlite
+ */
+ async findSqlitePathsForHost(storagePath, sanitizedHost) {
+ const sqlitePaths = [];
+ const idbPaths = await this.findIDBPathsForHost(storagePath, sanitizedHost);
+ for (const idbPath of idbPaths) {
+ const children = await IOUtils.getChildren(idbPath);
+
+ for (const path of children) {
+ const exists = await IOUtils.exists(path);
+ if (!exists) {
+ continue;
+ }
+
+ const stats = await IOUtils.stat(path);
+ if (stats.type !== "directory" && stats.path.endsWith(".sqlite")) {
+ sqlitePaths.push(path);
+ }
+ }
+ }
+ return sqlitePaths;
+ }
+
+ /**
+ * Find all paths that hold IndexedDB data for a host, such as:
+ * storage/temporary/http+++www.example.com/idb
+ */
+ async findIDBPathsForHost(storagePath, sanitizedHost) {
+ const idbPaths = [];
+ const typePaths = await this.findStorageTypePaths(storagePath);
+ for (const typePath of typePaths) {
+ const idbPath = PathUtils.join(typePath, sanitizedHost, "idb");
+ if (await IOUtils.exists(idbPath)) {
+ idbPaths.push(idbPath);
+ }
+ }
+ return idbPaths;
+ }
+
+ /**
+ * Find all the storage types, such as "default", "permanent", or "temporary".
+ * These names have changed over time, so it seems simpler to look through all
+ * types that currently exist in the profile.
+ */
+ async findStorageTypePaths(storagePath) {
+ const children = await IOUtils.getChildren(storagePath);
+ const typePaths = [];
+
+ for (const path of children) {
+ const exists = await IOUtils.exists(path);
+ if (!exists) {
+ continue;
+ }
+
+ const stats = await IOUtils.stat(path);
+ if (stats.type === "directory") {
+ typePaths.push(path);
+ }
+ }
+
+ return typePaths;
+ }
+
+ /**
+ * Removes any illegal characters from the host name to make it a valid file
+ * name.
+ */
+ getSanitizedHost(host) {
+ if (host.startsWith("about:")) {
+ host = "moz-safe-" + host;
+ }
+ return host.replace(ILLEGAL_CHAR_REGEX, "+");
+ }
+
+ /**
+ * Retrieves the proper indexed db database name from the provided .sqlite
+ * file location.
+ */
+ async getNameFromDatabaseFile(path) {
+ let connection = null;
+ let retryCount = 0;
+
+ // Content pages might be having an open transaction for the same indexed db
+ // which this sqlite file belongs to. In that case, sqlite.openConnection
+ // will throw. Thus we retry for some time to see if lock is removed.
+ while (!connection && retryCount++ < 25) {
+ try {
+ connection = await lazy.Sqlite.openConnection({ path });
+ } catch (ex) {
+ // Continuously retrying is overkill. Waiting for 100ms before next try
+ await sleep(100);
+ }
+ }
+
+ if (!connection) {
+ return null;
+ }
+
+ const rows = await connection.execute("SELECT name FROM database");
+ if (rows.length != 1) {
+ return null;
+ }
+
+ const name = rows[0].getResultByName("name");
+
+ await connection.close();
+
+ return name;
+ }
+
+ async getValuesForHost(
+ host,
+ name = "null",
+ options,
+ hostVsStores,
+ principal
+ ) {
+ name = JSON.parse(name);
+ if (!name || !name.length) {
+ // This means that details about the db in this particular host are
+ // requested.
+ const dbs = [];
+ if (hostVsStores.has(host)) {
+ for (let [, db] of hostVsStores.get(host)) {
+ db = this.patchMetadataMapsAndProtos(db);
+ dbs.push(db.toObject());
+ }
+ }
+ return { dbs };
+ }
+
+ const [db2, objectStore, id] = name;
+ if (!objectStore) {
+ // This means that details about all the object stores in this db are
+ // requested.
+ const objectStores = [];
+ if (hostVsStores.has(host) && hostVsStores.get(host).has(db2)) {
+ let db = hostVsStores.get(host).get(db2);
+
+ db = this.patchMetadataMapsAndProtos(db);
+
+ const objectStores2 = db.objectStores;
+
+ for (const objectStore2 of objectStores2) {
+ objectStores.push(objectStore2[1].toObject());
+ }
+ }
+ return {
+ objectStores,
+ };
+ }
+ // Get either all entries from the object store, or a particular id
+ const storage = hostVsStores.get(host).get(db2).storage;
+ const result = await this.getObjectStoreData(
+ host,
+ principal,
+ db2,
+ storage,
+ {
+ objectStore,
+ id,
+ index: options.index,
+ offset: options.offset,
+ size: options.size,
+ }
+ );
+ return { result };
+ }
+
+ /**
+ * Returns requested entries (or at most MAX_STORE_OBJECT_COUNT) from a particular
+ * objectStore from the db in the given host.
+ *
+ * @param {string} host
+ * The given host.
+ * @param {nsIPrincipal} principal
+ * The principal of the given document.
+ * @param {string} dbName
+ * The name of the indexed db from the above host.
+ * @param {String} storage
+ * Storage type, either "temporary", "default" or "persistent".
+ * @param {Object} requestOptions
+ * An object in the following format:
+ * {
+ * objectStore: The name of the object store from the above db,
+ * id: Id of the requested entry from the above object
+ * store. null if all entries from the above object
+ * store are requested,
+ * index: Name of the IDBIndex to be iterated on while fetching
+ * entries. null or "name" if no index is to be
+ * iterated,
+ * offset: offset of the entries to be fetched,
+ * size: The intended size of the entries to be fetched
+ * }
+ */
+ getObjectStoreData(host, principal, dbName, storage, requestOptions) {
+ const { name } = this.splitNameAndStorage(dbName);
+ const request = this.openWithPrincipal(principal, name, storage);
+
+ return new Promise((resolve, reject) => {
+ let { objectStore, id, index, offset, size } = requestOptions;
+ const data = [];
+ let db;
+
+ if (!size || size > MAX_STORE_OBJECT_COUNT) {
+ size = MAX_STORE_OBJECT_COUNT;
+ }
+
+ request.onsuccess = event => {
+ db = event.target.result;
+
+ const transaction = db.transaction(objectStore, "readonly");
+ let source = transaction.objectStore(objectStore);
+ if (index && index != "name") {
+ source = source.index(index);
+ }
+
+ source.count().onsuccess = event2 => {
+ const objectsSize = [];
+ const count = event2.target.result;
+ objectsSize.push({
+ key: host + dbName + objectStore + index,
+ count,
+ });
+
+ if (!offset) {
+ offset = 0;
+ } else if (offset > count) {
+ db.close();
+ resolve([]);
+ return;
+ }
+
+ if (id) {
+ source.get(id).onsuccess = event3 => {
+ db.close();
+ resolve([{ name: id, value: event3.target.result }]);
+ };
+ } else {
+ source.openCursor().onsuccess = event4 => {
+ const cursor = event4.target.result;
+
+ if (!cursor || data.length >= size) {
+ db.close();
+ resolve({
+ data,
+ objectsSize,
+ });
+ return;
+ }
+ if (offset-- <= 0) {
+ data.push({ name: cursor.key, value: cursor.value });
+ }
+ cursor.continue();
+ };
+ }
+ };
+ };
+
+ request.onerror = () => {
+ db.close();
+ resolve([]);
+ };
+ });
+ }
+
+ /**
+ * When indexedDB metadata is parsed to and from JSON then the object's
+ * prototype is dropped and any Maps are changed to arrays of arrays. This
+ * method is used to repair the prototypes and fix any broken Maps.
+ */
+ patchMetadataMapsAndProtos(metadata) {
+ const md = Object.create(DatabaseMetadata.prototype);
+ Object.assign(md, metadata);
+
+ md._objectStores = new Map(metadata._objectStores);
+
+ for (const [name, store] of md._objectStores) {
+ const obj = Object.create(ObjectStoreMetadata.prototype);
+ Object.assign(obj, store);
+
+ md._objectStores.set(name, obj);
+
+ if (typeof store._indexes.length !== "undefined") {
+ obj._indexes = new Map(store._indexes);
+ }
+
+ for (const [name2, value] of obj._indexes) {
+ const obj2 = Object.create(IndexMetadata.prototype);
+ Object.assign(obj2, value);
+
+ obj._indexes.set(name2, obj2);
+ }
+ }
+
+ return md;
+ }
+}
+exports.IndexedDBStorageActor = IndexedDBStorageActor;
diff --git a/devtools/server/actors/resources/storage/local-and-session-storage.js b/devtools/server/actors/resources/storage/local-and-session-storage.js
new file mode 100644
index 0000000000..ba0f006d22
--- /dev/null
+++ b/devtools/server/actors/resources/storage/local-and-session-storage.js
@@ -0,0 +1,200 @@
+/* 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 {
+ BaseStorageActor,
+ DEFAULT_VALUE,
+} = require("resource://devtools/server/actors/resources/storage/index.js");
+const {
+ LongStringActor,
+} = require("resource://devtools/server/actors/string.js");
+
+class LocalOrSessionStorageActor extends BaseStorageActor {
+ constructor(storageActor, typeName) {
+ super(storageActor, typeName);
+
+ Services.obs.addObserver(this, "dom-storage2-changed");
+ Services.obs.addObserver(this, "dom-private-storage2-changed");
+ }
+
+ destroy() {
+ if (this.isDestroyed()) {
+ return;
+ }
+ Services.obs.removeObserver(this, "dom-storage2-changed");
+ Services.obs.removeObserver(this, "dom-private-storage2-changed");
+
+ super.destroy();
+ }
+
+ getNamesForHost(host) {
+ const storage = this.hostVsStores.get(host);
+ return storage ? Object.keys(storage) : [];
+ }
+
+ getValuesForHost(host, name) {
+ const storage = this.hostVsStores.get(host);
+ if (!storage) {
+ return [];
+ }
+ if (name) {
+ const value = storage ? storage.getItem(name) : null;
+ return [{ name, value }];
+ }
+ if (!storage) {
+ return [];
+ }
+
+ // local and session storage cannot be iterated over using Object.keys()
+ // because it skips keys that are duplicated on the prototype
+ // e.g. "key", "getKeys" so we need to gather the real keys using the
+ // storage.key() function.
+ const storageArray = [];
+ for (let i = 0; i < storage.length; i++) {
+ const key = storage.key(i);
+ storageArray.push({
+ name: key,
+ value: storage.getItem(key),
+ });
+ }
+ return storageArray;
+ }
+
+ // We need to override this method as populateStoresForHost expect the window object
+ populateStoresForHosts() {
+ this.hostVsStores = new Map();
+ for (const window of this.windows) {
+ const host = this.getHostName(window.location);
+ if (host) {
+ this.populateStoresForHost(host, window);
+ }
+ }
+ }
+
+ populateStoresForHost(host, window) {
+ try {
+ this.hostVsStores.set(host, window[this.typeName]);
+ } catch (ex) {
+ console.warn(
+ `Failed to enumerate ${this.typeName} for host ${host}: ${ex}`
+ );
+ }
+ }
+
+ async getFields() {
+ return [
+ { name: "name", editable: true },
+ { name: "value", editable: true },
+ ];
+ }
+
+ async addItem(guid, host) {
+ const storage = this.hostVsStores.get(host);
+ if (!storage) {
+ return;
+ }
+ storage.setItem(guid, DEFAULT_VALUE);
+ }
+
+ /**
+ * Edit localStorage or sessionStorage fields.
+ *
+ * @param {Object} data
+ * See editCookie() for format details.
+ */
+ async editItem({ host, field, oldValue, items }) {
+ const storage = this.hostVsStores.get(host);
+ if (!storage) {
+ return;
+ }
+
+ if (field === "name") {
+ storage.removeItem(oldValue);
+ }
+
+ storage.setItem(items.name, items.value);
+ }
+
+ async removeItem(host, name) {
+ const storage = this.hostVsStores.get(host);
+ if (!storage) {
+ return;
+ }
+ storage.removeItem(name);
+ }
+
+ async removeAll(host) {
+ const storage = this.hostVsStores.get(host);
+ if (!storage) {
+ return;
+ }
+ storage.clear();
+ }
+
+ observe(subject, topic, data) {
+ if (
+ (topic != "dom-storage2-changed" &&
+ topic != "dom-private-storage2-changed") ||
+ data != this.typeName
+ ) {
+ return null;
+ }
+
+ const host = this.getSchemaAndHost(subject.url);
+
+ if (!this.hostVsStores.has(host)) {
+ return null;
+ }
+
+ let action = "changed";
+ if (subject.key == null) {
+ return this.storageActor.update("cleared", this.typeName, [host]);
+ } else if (subject.oldValue == null) {
+ action = "added";
+ } else if (subject.newValue == null) {
+ action = "deleted";
+ }
+ const updateData = {};
+ updateData[host] = [subject.key];
+ return this.storageActor.update(action, this.typeName, updateData);
+ }
+
+ /**
+ * Given a url, correctly determine its protocol + hostname part.
+ */
+ getSchemaAndHost(url) {
+ const uri = Services.io.newURI(url);
+ if (!uri.host) {
+ return uri.spec;
+ }
+ return uri.scheme + "://" + uri.hostPort;
+ }
+
+ toStoreObject(item) {
+ if (!item) {
+ return null;
+ }
+
+ return {
+ name: item.name,
+ value: new LongStringActor(this.conn, item.value || ""),
+ };
+ }
+}
+
+class LocalStorageActor extends LocalOrSessionStorageActor {
+ constructor(storageActor) {
+ super(storageActor, "localStorage");
+ }
+}
+exports.LocalStorageActor = LocalStorageActor;
+
+class SessionStorageActor extends LocalOrSessionStorageActor {
+ constructor(storageActor) {
+ super(storageActor, "sessionStorage");
+ }
+}
+exports.SessionStorageActor = SessionStorageActor;
diff --git a/devtools/server/actors/resources/storage/moz.build b/devtools/server/actors/resources/storage/moz.build
new file mode 100644
index 0000000000..1615254759
--- /dev/null
+++ b/devtools/server/actors/resources/storage/moz.build
@@ -0,0 +1,17 @@
+# -*- 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(
+ "cache.js",
+ "cookies.js",
+ "extension-storage.js",
+ "index.js",
+ "indexed-db.js",
+ "local-and-session-storage.js",
+)
+
+with Files("**"):
+ BUG_COMPONENT = ("DevTools", "Storage Inspector")
diff --git a/devtools/server/actors/resources/stylesheets.js b/devtools/server/actors/resources/stylesheets.js
new file mode 100644
index 0000000000..ef0b1b0f75
--- /dev/null
+++ b/devtools/server/actors/resources/stylesheets.js
@@ -0,0 +1,136 @@
+/* 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 {
+ TYPES: { STYLESHEET },
+} = require("resource://devtools/server/actors/resources/index.js");
+
+loader.lazyRequireGetter(
+ this,
+ "CssLogic",
+ "resource://devtools/shared/inspector/css-logic.js"
+);
+
+class StyleSheetWatcher {
+ constructor() {
+ this._onApplicableStylesheetAdded =
+ this._onApplicableStylesheetAdded.bind(this);
+ this._onStylesheetUpdated = this._onStylesheetUpdated.bind(this);
+ }
+
+ /**
+ * Start watching for all stylesheets related to a given Target Actor.
+ *
+ * @param TargetActor targetActor
+ * The target actor from which we should observe css changes.
+ * @param Object options
+ * Dictionary object with following attributes:
+ * - onAvailable: mandatory function
+ * This will be called for each resource.
+ */
+ async watch(targetActor, { onAvailable, onUpdated }) {
+ this._targetActor = targetActor;
+ this._onAvailable = onAvailable;
+ this._onUpdated = onUpdated;
+
+ this._styleSheetsManager = targetActor.getStyleSheetsManager();
+
+ // Add event listener for new additions and updates
+ this._styleSheetsManager.on(
+ "applicable-stylesheet-added",
+ this._onApplicableStylesheetAdded
+ );
+ this._styleSheetsManager.on(
+ "stylesheet-updated",
+ this._onStylesheetUpdated
+ );
+
+ // startWatching will emit applicable-stylesheet-added for already existing stylesheet
+ await this._styleSheetsManager.startWatching();
+ }
+
+ _onApplicableStylesheetAdded(styleSheetData) {
+ return this._notifyResourcesAvailable([styleSheetData]);
+ }
+
+ _onStylesheetUpdated({ resourceId, updateKind, updates = {} }) {
+ this._notifyResourceUpdated(resourceId, updateKind, updates);
+ }
+
+ async _toResource(
+ styleSheet,
+ { isCreatedByDevTools = false, fileName = null, resourceId } = {}
+ ) {
+ const resource = {
+ resourceId,
+ resourceType: STYLESHEET,
+ disabled: styleSheet.disabled,
+ constructed: styleSheet.constructed,
+ fileName,
+ href: styleSheet.href,
+ isNew: isCreatedByDevTools,
+ atRules: await this._styleSheetsManager.getAtRules(styleSheet),
+ nodeHref: this._styleSheetsManager._getNodeHref(styleSheet),
+ ruleCount: styleSheet.cssRules.length,
+ sourceMapBaseURL:
+ this._styleSheetsManager._getSourcemapBaseURL(styleSheet),
+ sourceMapURL: styleSheet.sourceMapURL,
+ styleSheetIndex: this._styleSheetsManager._getStyleSheetIndex(styleSheet),
+ system: CssLogic.isAgentStylesheet(styleSheet),
+ title: styleSheet.title,
+ };
+
+ return resource;
+ }
+
+ async _notifyResourcesAvailable(styleSheets) {
+ const resources = await Promise.all(
+ styleSheets.map(async ({ resourceId, styleSheet, creationData }) => {
+ const resource = await this._toResource(styleSheet, {
+ resourceId,
+ isCreatedByDevTools: creationData?.isCreatedByDevTools,
+ fileName: creationData?.fileName,
+ });
+
+ return resource;
+ })
+ );
+
+ await this._onAvailable(resources);
+ }
+
+ _notifyResourceUpdated(
+ resourceId,
+ updateType,
+ { resourceUpdates, nestedResourceUpdates, event }
+ ) {
+ this._onUpdated([
+ {
+ browsingContextID: this._targetActor.browsingContextID,
+ innerWindowId: this._targetActor.innerWindowId,
+ resourceType: STYLESHEET,
+ resourceId,
+ updateType,
+ resourceUpdates,
+ nestedResourceUpdates,
+ event,
+ },
+ ]);
+ }
+
+ destroy() {
+ this._styleSheetsManager.off(
+ "applicable-stylesheet-added",
+ this._onApplicableStylesheetAdded
+ );
+ this._styleSheetsManager.off(
+ "stylesheet-updated",
+ this._onStylesheetUpdated
+ );
+ }
+}
+
+module.exports = StyleSheetWatcher;
diff --git a/devtools/server/actors/resources/thread-states.js b/devtools/server/actors/resources/thread-states.js
new file mode 100644
index 0000000000..9ac79088d2
--- /dev/null
+++ b/devtools/server/actors/resources/thread-states.js
@@ -0,0 +1,136 @@
+/* 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 {
+ TYPES: { THREAD_STATE },
+} = require("resource://devtools/server/actors/resources/index.js");
+
+const {
+ PAUSE_REASONS,
+ STATES: THREAD_STATES,
+} = require("resource://devtools/server/actors/thread.js");
+
+// Possible values of breakpoint's resource's `state` attribute
+const STATES = {
+ PAUSED: "paused",
+ RESUMED: "resumed",
+};
+
+/**
+ * Emit THREAD_STATE resources, which is emitted each time the target's thread pauses or resumes.
+ * So that there is two distinct values for this resource: pauses and resumes.
+ * These values are distinguished by `state` attribute which can be either "paused" or "resumed".
+ *
+ * Resume events, won't expose any other attribute other than `resourceType` and `state`.
+ *
+ * Pause events will expose the following attributes:
+ * - why {Object}: Description of why the thread pauses. See ThreadActor's PAUSE_REASONS definition for more information.
+ * - frame {Object}: Description of the frame where we just paused. This is a FrameActor's form.
+ */
+class BreakpointWatcher {
+ constructor() {
+ this.onPaused = this.onPaused.bind(this);
+ this.onResumed = this.onResumed.bind(this);
+ }
+
+ /**
+ * Start watching for state changes of the thread actor.
+ * This will notify whenever the thread actor pause and resume.
+ *
+ * @param TargetActor targetActor
+ * The target actor from which we should observe breakpoints
+ * @param Object options
+ * Dictionary object with following attributes:
+ * - onAvailable: mandatory function
+ * This will be called for each resource.
+ */
+ async watch(targetActor, { onAvailable }) {
+ const { threadActor } = targetActor;
+ this.threadActor = threadActor;
+ this.onAvailable = onAvailable;
+
+ // If this watcher is created during target creation, attach the thread actor automatically.
+ // Otherwise it would not pause on anything (especially debugger statements).
+ // However, do not attach the thread actor for Workers. They use a codepath
+ // which releases the worker on `attach`. For them, the client will call `attach`. (bug 1691986)
+ const isTargetCreation = this.threadActor.state == THREAD_STATES.DETACHED;
+ if (isTargetCreation && !targetActor.targetType.endsWith("worker")) {
+ await this.threadActor.attach({});
+ }
+
+ this.isInterrupted = false;
+
+ threadActor.on("paused", this.onPaused);
+ threadActor.on("resumed", this.onResumed);
+
+ // For top-level targets, the thread actor may have been attached by the frontend
+ // on toolbox opening, and we start observing for thread state updates much later.
+ // In which case, the thread actor may already be paused and we handle this here.
+ // It will also occurs for all other targets once bug 1681698 lands,
+ // as the thread actor will be initialized before the target starts loading.
+ // And it will occur for all targets once bug 1686748 lands.
+ //
+ // Note that we have to check if we have a "lastPausedPacket",
+ // because the thread Actor is immediately set as being paused,
+ // but the pause packet is built asynchronously and available slightly later.
+ // If the "lastPausedPacket" is null, while the thread actor is paused,
+ // it is fine to ignore as the "paused" event will be fire later.
+ if (threadActor.isPaused() && threadActor.lastPausedPacket()) {
+ this.onPaused(threadActor.lastPausedPacket());
+ }
+ }
+
+ /**
+ * Stop watching for breakpoints
+ */
+ destroy() {
+ this.threadActor.off("paused", this.onPaused);
+ this.threadActor.off("resumed", this.onResumed);
+ }
+
+ onPaused(packet) {
+ // If paused by an explicit interrupt, which are generated by the
+ // slow script dialog and internal events such as setting
+ // breakpoints, ignore the event.
+ const { why } = packet;
+ if (why.type === PAUSE_REASONS.INTERRUPTED && !why.onNext) {
+ this.isInterrupted = true;
+ return;
+ }
+
+ // Ignore attached events because they are not useful to the user.
+ if (why.type == PAUSE_REASONS.ALREADY_PAUSED) {
+ return;
+ }
+
+ this.onAvailable([
+ {
+ resourceType: THREAD_STATE,
+ state: STATES.PAUSED,
+ why,
+ frame: packet.frame.form(),
+ },
+ ]);
+ }
+
+ onResumed(packet) {
+ // NOTE: resumed events are suppressed while interrupted
+ // to prevent unintentional behavior.
+ if (this.isInterrupted) {
+ this.isInterrupted = false;
+ return;
+ }
+
+ this.onAvailable([
+ {
+ resourceType: THREAD_STATE,
+ state: STATES.RESUMED,
+ },
+ ]);
+ }
+}
+
+module.exports = BreakpointWatcher;
diff --git a/devtools/server/actors/resources/tracing-state.js b/devtools/server/actors/resources/tracing-state.js
new file mode 100644
index 0000000000..7a11a85ff8
--- /dev/null
+++ b/devtools/server/actors/resources/tracing-state.js
@@ -0,0 +1,63 @@
+/* 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 {
+ TYPES: { TRACING_STATE },
+} = require("resource://devtools/server/actors/resources/index.js");
+
+// Bug 1827382, as this module can be used from the worker thread,
+// the following JSM may be loaded by the worker loader until
+// we have proper support for ESM from workers.
+const {
+ addTracingListener,
+ removeTracingListener,
+} = require("resource://devtools/server/tracer/tracer.jsm");
+
+class TracingStateWatcher {
+ /**
+ * Start watching for tracing state changes for a given target actor.
+ *
+ * @param TargetActor targetActor
+ * The target actor from which we should observe
+ * @param Object options
+ * Dictionary object with following attributes:
+ * - onAvailable: mandatory function
+ * This will be called for each resource.
+ */
+ async watch(targetActor, { onAvailable }) {
+ this.targetActor = targetActor;
+ this.onAvailable = onAvailable;
+
+ this.tracingListener = {
+ onTracingToggled: this.onTracingToggled.bind(this),
+ };
+ addTracingListener(this.tracingListener);
+ }
+
+ /**
+ * Stop watching for tracing state
+ */
+ destroy() {
+ removeTracingListener(this.tracingListener);
+ }
+
+ // Emit a TRACING_STATE resource with:
+ // enabled = true|false
+ // When Javascript tracing is enabled or disabled.
+ onTracingToggled(enabled) {
+ const tracerActor = this.targetActor.getTargetScopedActor("tracer");
+ const logMethod = tracerActor?.getLogMethod() | "stdout";
+ this.onAvailable([
+ {
+ resourceType: TRACING_STATE,
+ enabled,
+ logMethod,
+ },
+ ]);
+ }
+}
+
+module.exports = TracingStateWatcher;
diff --git a/devtools/server/actors/resources/utils/content-process-storage.js b/devtools/server/actors/resources/utils/content-process-storage.js
new file mode 100644
index 0000000000..8bb0016a3e
--- /dev/null
+++ b/devtools/server/actors/resources/utils/content-process-storage.js
@@ -0,0 +1,453 @@
+/* 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 EventEmitter = require("resource://devtools/shared/event-emitter.js");
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ getAddonIdForWindowGlobal:
+ "resource://devtools/server/actors/watcher/browsing-context-helpers.sys.mjs",
+});
+
+// ms of delay to throttle updates
+const BATCH_DELAY = 200;
+
+// Filters "stores-update" response to only include events for
+// the storage type we desire
+function getFilteredStorageEvents(updates, storageType) {
+ const filteredUpdate = Object.create(null);
+
+ // updateType will be "added", "changed", or "deleted"
+ for (const updateType in updates) {
+ if (updates[updateType][storageType]) {
+ if (!filteredUpdate[updateType]) {
+ filteredUpdate[updateType] = {};
+ }
+ filteredUpdate[updateType][storageType] =
+ updates[updateType][storageType];
+ }
+ }
+
+ return Object.keys(filteredUpdate).length ? filteredUpdate : null;
+}
+
+class ContentProcessStorage {
+ constructor(ActorConstructor, storageKey, storageType) {
+ this.ActorConstructor = ActorConstructor;
+ this.storageKey = storageKey;
+ this.storageType = storageType;
+
+ this.onStoresUpdate = this.onStoresUpdate.bind(this);
+ this.onStoresCleared = this.onStoresCleared.bind(this);
+ }
+
+ async watch(targetActor, { onAvailable }) {
+ const storageActor = new StorageActorMock(targetActor);
+ this.storageActor = storageActor;
+ this.actor = new this.ActorConstructor(storageActor);
+
+ // Some storage types require to prelist their stores
+ await this.actor.populateStoresForHosts();
+
+ // We have to manage the actor manually, because ResourceCommand doesn't
+ // use the protocol.js specification.
+ // resource-available-form is typed as "json"
+ // So that we have to manually handle stuff that would normally be
+ // automagically done by procotol.js
+ // 1) Manage the actor in order to have an actorID on it
+ targetActor.manage(this.actor);
+ // 2) Convert to JSON "form"
+ const form = this.actor.form();
+
+ // NOTE: this is hoisted, so the `update` method above may use it.
+ const storage = form;
+
+ // All resources should have a resourceType, resourceId and resourceKey
+ // attributes, so available/updated/destroyed callbacks work properly.
+ storage.resourceType = this.storageType;
+ storage.resourceId = this.storageType;
+ storage.resourceKey = this.storageKey;
+
+ onAvailable([storage]);
+
+ // Maps global events from `storageActor` shared for all storage-types,
+ // down to storage-type's specific actor `storage`.
+ storageActor.on("stores-update", this.onStoresUpdate);
+
+ // When a store gets cleared
+ storageActor.on("stores-cleared", this.onStoresCleared);
+ }
+
+ onStoresUpdate(response) {
+ response = getFilteredStorageEvents(response, this.storageKey);
+ if (!response) {
+ return;
+ }
+ this.actor.emit("single-store-update", {
+ changed: response.changed,
+ added: response.added,
+ deleted: response.deleted,
+ });
+ }
+
+ onStoresCleared(response) {
+ const cleared = response[this.storageKey];
+
+ if (!cleared) {
+ return;
+ }
+
+ this.actor.emit("single-store-cleared", {
+ clearedHostsOrPaths: cleared,
+ });
+ }
+
+ destroy() {
+ this.actor?.destroy();
+ this.actor = null;
+ if (this.storageActor) {
+ this.storageActor.on("stores-update", this.onStoresUpdate);
+ this.storageActor.on("stores-cleared", this.onStoresCleared);
+ this.storageActor.destroy();
+ this.storageActor = null;
+ }
+ }
+}
+
+module.exports = ContentProcessStorage;
+
+// This class mocks what used to be implement in devtools/server/actors/storage.js: StorageActor
+// But without being a protocol.js actor, nor implement any RDP method/event.
+// An instance of this class is passed to each storage type actor and named `storageActor`.
+// Once we implement all storage type in watcher classes, we can get rid of the original
+// StorageActor in devtools/server/actors/storage.js
+class StorageActorMock extends EventEmitter {
+ constructor(targetActor) {
+ super();
+ // Storage classes fetch conn from storageActor
+ this.conn = targetActor.conn;
+ this.targetActor = targetActor;
+
+ this.childWindowPool = new Set();
+
+ // Fetch all the inner iframe windows in this tab.
+ this.fetchChildWindows(this.targetActor.docShell);
+
+ // Notifications that help us keep track of newly added windows and windows
+ // that got removed
+ Services.obs.addObserver(this, "content-document-global-created");
+ Services.obs.addObserver(this, "inner-window-destroyed");
+ this.onPageChange = this.onPageChange.bind(this);
+
+ const handler = targetActor.chromeEventHandler;
+ handler.addEventListener("pageshow", this.onPageChange, true);
+ handler.addEventListener("pagehide", this.onPageChange, true);
+
+ this.destroyed = false;
+ this.boundUpdate = {};
+ }
+
+ destroy() {
+ clearTimeout(this.batchTimer);
+ this.batchTimer = null;
+ // Remove observers
+ Services.obs.removeObserver(this, "content-document-global-created");
+ Services.obs.removeObserver(this, "inner-window-destroyed");
+ this.destroyed = true;
+ if (this.targetActor.browser) {
+ this.targetActor.browser.removeEventListener(
+ "pageshow",
+ this.onPageChange,
+ true
+ );
+ this.targetActor.browser.removeEventListener(
+ "pagehide",
+ this.onPageChange,
+ true
+ );
+ }
+ this.childWindowPool.clear();
+
+ this.childWindowPool = null;
+ this.targetActor = null;
+ this.boundUpdate = null;
+ }
+
+ get window() {
+ return this.targetActor.window;
+ }
+
+ get document() {
+ return this.targetActor.window.document;
+ }
+
+ get windows() {
+ return this.childWindowPool;
+ }
+
+ /**
+ * Given a docshell, recursively find out all the child windows from it.
+ *
+ * @param {nsIDocShell} item
+ * The docshell from which all inner windows need to be extracted.
+ */
+ fetchChildWindows(item) {
+ const docShell = item
+ .QueryInterface(Ci.nsIDocShell)
+ .QueryInterface(Ci.nsIDocShellTreeItem);
+ if (!docShell.contentViewer) {
+ return null;
+ }
+ const window = docShell.contentViewer.DOMDocument.defaultView;
+ if (window.location.href == "about:blank") {
+ // Skip out about:blank windows as Gecko creates them multiple times while
+ // creating any global.
+ return null;
+ }
+ if (!this.isIncludedInTopLevelWindow(window)) {
+ return null;
+ }
+ this.childWindowPool.add(window);
+ for (let i = 0; i < docShell.childCount; i++) {
+ const child = docShell.getChildAt(i);
+ this.fetchChildWindows(child);
+ }
+ return null;
+ }
+
+ isIncludedInTargetExtension(subject) {
+ const addonId = lazy.getAddonIdForWindowGlobal(subject.windowGlobalChild);
+ return addonId && addonId === this.targetActor.addonId;
+ }
+
+ isIncludedInTopLevelWindow(window) {
+ return this.targetActor.windows.includes(window);
+ }
+
+ getWindowFromInnerWindowID(innerID) {
+ innerID = innerID.QueryInterface(Ci.nsISupportsPRUint64).data;
+ for (const win of this.childWindowPool.values()) {
+ const id = win.windowGlobalChild.innerWindowId;
+ if (id == innerID) {
+ return win;
+ }
+ }
+ return null;
+ }
+
+ getWindowFromHost(host) {
+ for (const win of this.childWindowPool.values()) {
+ const origin = win.document.nodePrincipal.originNoSuffix;
+ const url = win.document.URL;
+ if (origin === host || url === host) {
+ return win;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Event handler for any docshell update. This lets us figure out whenever
+ * any new window is added, or an existing window is removed.
+ */
+ observe(subject, topic) {
+ if (
+ subject.location &&
+ (!subject.location.href || subject.location.href == "about:blank")
+ ) {
+ return null;
+ }
+
+ // We don't want to try to find a top level window for an extension page, as
+ // in many cases (e.g. background page), it is not loaded in a tab, and
+ // 'isIncludedInTopLevelWindow' throws an error
+ if (
+ topic == "content-document-global-created" &&
+ (this.isIncludedInTargetExtension(subject) ||
+ this.isIncludedInTopLevelWindow(subject))
+ ) {
+ this.childWindowPool.add(subject);
+ this.emit("window-ready", subject);
+ } else if (topic == "inner-window-destroyed") {
+ const window = this.getWindowFromInnerWindowID(subject);
+ if (window) {
+ this.childWindowPool.delete(window);
+ this.emit("window-destroyed", window);
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Called on "pageshow" or "pagehide" event on the chromeEventHandler of
+ * current tab.
+ *
+ * @param {event} The event object passed to the handler. We are using these
+ * three properties from the event:
+ * - target {document} The document corresponding to the event.
+ * - type {string} Name of the event - "pageshow" or "pagehide".
+ * - persisted {boolean} true if there was no
+ * "content-document-global-created" notification along
+ * this event.
+ */
+ onPageChange({ target, type, persisted }) {
+ if (this.destroyed) {
+ return;
+ }
+
+ const window = target.defaultView;
+
+ if (type == "pagehide" && this.childWindowPool.delete(window)) {
+ this.emit("window-destroyed", window);
+ } else if (
+ type == "pageshow" &&
+ persisted &&
+ window.location.href &&
+ window.location.href != "about:blank" &&
+ this.isIncludedInTopLevelWindow(window)
+ ) {
+ this.childWindowPool.add(window);
+ this.emit("window-ready", window);
+ }
+ }
+
+ /**
+ * This method is called by the registered storage types so as to tell the
+ * Storage Actor that there are some changes in the stores. Storage Actor then
+ * notifies the client front about these changes at regular (BATCH_DELAY)
+ * interval.
+ *
+ * @param {string} action
+ * The type of change. One of "added", "changed" or "deleted"
+ * @param {string} storeType
+ * The storage actor in which this change has occurred.
+ * @param {object} data
+ * The update object. This object is of the following format:
+ * - {
+ * <host1>: [<store_names1>, <store_name2>...],
+ * <host2>: [<store_names34>...],
+ * }
+ * Where host1, host2 are the host in which this change happened and
+ * [<store_namesX] is an array of the names of the changed store objects.
+ * Pass an empty array if the host itself was affected: either completely
+ * removed or cleared.
+ */
+ // eslint-disable-next-line complexity
+ update(action, storeType, data) {
+ if (action == "cleared") {
+ this.emit("stores-cleared", { [storeType]: data });
+ return null;
+ }
+
+ if (this.batchTimer) {
+ clearTimeout(this.batchTimer);
+ }
+ if (!this.boundUpdate[action]) {
+ this.boundUpdate[action] = {};
+ }
+ if (!this.boundUpdate[action][storeType]) {
+ this.boundUpdate[action][storeType] = {};
+ }
+ for (const host in data) {
+ if (!this.boundUpdate[action][storeType][host]) {
+ this.boundUpdate[action][storeType][host] = [];
+ }
+ for (const name of data[host]) {
+ if (!this.boundUpdate[action][storeType][host].includes(name)) {
+ this.boundUpdate[action][storeType][host].push(name);
+ }
+ }
+ }
+ if (action == "added") {
+ // If the same store name was previously deleted or changed, but now is
+ // added somehow, dont send the deleted or changed update.
+ this.removeNamesFromUpdateList("deleted", storeType, data);
+ this.removeNamesFromUpdateList("changed", storeType, data);
+ } else if (
+ action == "changed" &&
+ this.boundUpdate.added &&
+ this.boundUpdate.added[storeType]
+ ) {
+ // If something got added and changed at the same time, then remove those
+ // items from changed instead.
+ this.removeNamesFromUpdateList(
+ "changed",
+ storeType,
+ this.boundUpdate.added[storeType]
+ );
+ } else if (action == "deleted") {
+ // If any item got delete, or a host got delete, no point in sending
+ // added or changed update
+ this.removeNamesFromUpdateList("added", storeType, data);
+ this.removeNamesFromUpdateList("changed", storeType, data);
+
+ for (const host in data) {
+ if (
+ !data[host].length &&
+ this.boundUpdate.added &&
+ this.boundUpdate.added[storeType] &&
+ this.boundUpdate.added[storeType][host]
+ ) {
+ delete this.boundUpdate.added[storeType][host];
+ }
+ if (
+ !data[host].length &&
+ this.boundUpdate.changed &&
+ this.boundUpdate.changed[storeType] &&
+ this.boundUpdate.changed[storeType][host]
+ ) {
+ delete this.boundUpdate.changed[storeType][host];
+ }
+ }
+ }
+
+ this.batchTimer = setTimeout(() => {
+ clearTimeout(this.batchTimer);
+ this.emit("stores-update", this.boundUpdate);
+ this.boundUpdate = {};
+ }, BATCH_DELAY);
+
+ return null;
+ }
+
+ /**
+ * This method removes data from the this.boundUpdate object in the same
+ * manner like this.update() adds data to it.
+ *
+ * @param {string} action
+ * The type of change. One of "added", "changed" or "deleted"
+ * @param {string} storeType
+ * The storage actor for which you want to remove the updates data.
+ * @param {object} data
+ * The update object. This object is of the following format:
+ * - {
+ * <host1>: [<store_names1>, <store_name2>...],
+ * <host2>: [<store_names34>...],
+ * }
+ * Where host1, host2 are the hosts which you want to remove and
+ * [<store_namesX] is an array of the names of the store objects.
+ */
+ removeNamesFromUpdateList(action, storeType, data) {
+ for (const host in data) {
+ if (
+ this.boundUpdate[action] &&
+ this.boundUpdate[action][storeType] &&
+ this.boundUpdate[action][storeType][host]
+ ) {
+ for (const name in data[host]) {
+ const index = this.boundUpdate[action][storeType][host].indexOf(name);
+ if (index > -1) {
+ this.boundUpdate[action][storeType][host].splice(index, 1);
+ }
+ }
+ if (!this.boundUpdate[action][storeType][host].length) {
+ delete this.boundUpdate[action][storeType][host];
+ }
+ }
+ }
+ return null;
+ }
+}
diff --git a/devtools/server/actors/resources/utils/moz.build b/devtools/server/actors/resources/utils/moz.build
new file mode 100644
index 0000000000..0e6f9d1baa
--- /dev/null
+++ b/devtools/server/actors/resources/utils/moz.build
@@ -0,0 +1,14 @@
+# -*- 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(
+ "content-process-storage.js",
+ "nsi-console-listener-watcher.js",
+ "parent-process-storage.js",
+)
+
+with Files("nsi-console-listener-watcher.js"):
+ BUG_COMPONENT = ("DevTools", "Console")
diff --git a/devtools/server/actors/resources/utils/nsi-console-listener-watcher.js b/devtools/server/actors/resources/utils/nsi-console-listener-watcher.js
new file mode 100644
index 0000000000..8d1ed43612
--- /dev/null
+++ b/devtools/server/actors/resources/utils/nsi-console-listener-watcher.js
@@ -0,0 +1,192 @@
+/* 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 {
+ createStringGrip,
+} = require("resource://devtools/server/actors/object/utils.js");
+
+const {
+ getActorIdForInternalSourceId,
+} = require("resource://devtools/server/actors/utils/dbg-source.js");
+
+class nsIConsoleListenerWatcher {
+ /**
+ * Start watching for all messages related to a given Target Actor.
+ * This will notify about existing messages, as well as those created in the future.
+ *
+ * @param TargetActor targetActor
+ * The target actor from which we should observe messages
+ * @param Object options
+ * Dictionary object with following attributes:
+ * - onAvailable: mandatory function
+ * This will be called for each resource.
+ */
+ async watch(targetActor, { onAvailable }) {
+ if (!this.shouldHandleTarget(targetActor)) {
+ return;
+ }
+
+ let latestRetrievedCachedMessageTimestamp = -1;
+
+ // Create the consoleListener.
+ const listener = {
+ QueryInterface: ChromeUtils.generateQI(["nsIConsoleListener"]),
+ observe: message => {
+ if (
+ message.microSecondTimeStamp <= latestRetrievedCachedMessageTimestamp
+ ) {
+ return;
+ }
+
+ if (!this.shouldHandleMessage(targetActor, message)) {
+ return;
+ }
+
+ onAvailable([this.buildResource(targetActor, message)]);
+ },
+ };
+
+ // Retrieve the cached messages and get the last cached message timestamp before
+ // registering the listener, so we can ignore messages we'd be notified about but that
+ // were already retrieved in the cache.
+ const cachedMessages = Services.console.getMessageArray() || [];
+ if (cachedMessages.length) {
+ latestRetrievedCachedMessageTimestamp =
+ cachedMessages.at(-1).microSecondTimeStamp;
+ }
+
+ Services.console.registerListener(listener);
+ this.listener = listener;
+
+ // Remove unwanted cache messages and send an array of resources.
+ const messages = [];
+ for (const message of cachedMessages) {
+ if (!this.shouldHandleMessage(targetActor, message, true)) {
+ continue;
+ }
+
+ messages.push(this.buildResource(targetActor, message));
+ }
+ onAvailable(messages);
+ }
+
+ /**
+ * Return false if the watcher shouldn't be created.
+ *
+ * @param {TargetActor} targetActor
+ * @return {Boolean}
+ */
+ shouldHandleTarget(targetActor) {
+ return true;
+ }
+
+ /**
+ * Return true if you want the passed message to be handled by the watcher. This should
+ * be implemented on the child class.
+ *
+ * @param {TargetActor} targetActor
+ * @param {nsIScriptError|nsIConsoleMessage} message
+ * @return {Boolean}
+ */
+ shouldHandleMessage(targetActor, message) {
+ throw new Error(
+ "'shouldHandleMessage' should be implemented in the class that extends nsIConsoleListenerWatcher"
+ );
+ }
+
+ /**
+ * Prepare the resource to be sent to the client. This should be implemented on the
+ * child class.
+ *
+ * @param targetActor
+ * @param nsIScriptError|nsIConsoleMessage message
+ * @return object
+ * The object you can send to the remote client.
+ */
+ buildResource(targetActor, message) {
+ throw new Error(
+ "'buildResource' should be implemented in the class that extends nsIConsoleListenerWatcher"
+ );
+ }
+
+ /**
+ * Prepare a SavedFrame stack to be sent to the client.
+ *
+ * @param {TargetActor} targetActor
+ * @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(targetActor, 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: getActorIdForInternalSourceId(targetActor, 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 error notes to be sent to the client.
+ *
+ * @param {TargetActor} targetActor
+ * @param {nsIArray<nsIScriptErrorNote>} errorNotes
+ * @return object
+ * The object you can send to the remote client.
+ */
+ prepareNotesForRemote(targetActor, errorNotes) {
+ if (!errorNotes?.length) {
+ return null;
+ }
+
+ const notes = [];
+ for (let i = 0, len = errorNotes.length; i < len; i++) {
+ const note = errorNotes.queryElementAt(i, Ci.nsIScriptErrorNote);
+ notes.push({
+ messageBody: createStringGrip(targetActor, note.errorMessage),
+ frame: {
+ source: note.sourceName,
+ sourceId: getActorIdForInternalSourceId(targetActor, note.sourceId),
+ line: note.lineNumber,
+ column: note.columnNumber,
+ },
+ });
+ }
+ return notes;
+ }
+
+ isProcessTarget(targetActor) {
+ const { typeName } = targetActor;
+ return (
+ typeName === "parentProcessTarget" || typeName === "contentProcessTarget"
+ );
+ }
+
+ /**
+ * Stop watching for messages.
+ */
+ destroy() {
+ if (this.listener) {
+ Services.console.unregisterListener(this.listener);
+ }
+ }
+}
+module.exports = nsIConsoleListenerWatcher;
diff --git a/devtools/server/actors/resources/utils/parent-process-storage.js b/devtools/server/actors/resources/utils/parent-process-storage.js
new file mode 100644
index 0000000000..423d13b6b5
--- /dev/null
+++ b/devtools/server/actors/resources/utils/parent-process-storage.js
@@ -0,0 +1,580 @@
+/* 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 EventEmitter = require("resource://devtools/shared/event-emitter.js");
+const { isWindowGlobalPartOfContext } = ChromeUtils.importESModule(
+ "resource://devtools/server/actors/watcher/browsing-context-helpers.sys.mjs"
+);
+
+// ms of delay to throttle updates
+const BATCH_DELAY = 200;
+
+// Filters "stores-update" response to only include events for
+// the storage type we desire
+function getFilteredStorageEvents(updates, storageType) {
+ const filteredUpdate = Object.create(null);
+
+ // updateType will be "added", "changed", or "deleted"
+ for (const updateType in updates) {
+ if (updates[updateType][storageType]) {
+ if (!filteredUpdate[updateType]) {
+ filteredUpdate[updateType] = {};
+ }
+ filteredUpdate[updateType][storageType] =
+ updates[updateType][storageType];
+ }
+ }
+
+ return Object.keys(filteredUpdate).length ? filteredUpdate : null;
+}
+
+class ParentProcessStorage {
+ constructor(ActorConstructor, storageKey, storageType) {
+ this.ActorConstructor = ActorConstructor;
+ this.storageKey = storageKey;
+ this.storageType = storageType;
+
+ this.onStoresUpdate = this.onStoresUpdate.bind(this);
+ this.onStoresCleared = this.onStoresCleared.bind(this);
+
+ this.observe = this.observe.bind(this);
+ // Notifications that help us keep track of newly added windows and windows
+ // that got removed
+ Services.obs.addObserver(this, "window-global-created");
+ Services.obs.addObserver(this, "window-global-destroyed");
+
+ // bfcacheInParent is only enabled when fission is enabled
+ // and when Session History In Parent is enabled. (all three modes should now enabled all together)
+ loader.lazyGetter(
+ this,
+ "isBfcacheInParentEnabled",
+ () =>
+ Services.appinfo.sessionHistoryInParent &&
+ Services.prefs.getBoolPref("fission.bfcacheInParent", false)
+ );
+ }
+
+ async watch(watcherActor, { onAvailable }) {
+ this.watcherActor = watcherActor;
+ this.onAvailable = onAvailable;
+
+ // When doing a bfcache navigation with Fission disabled or with Fission + bfCacheInParent enabled,
+ // we're not getting a the window-global-created events.
+ // In such case, the watcher emits specific events that we can use instead.
+ this._offPageShow = watcherActor.on(
+ "bf-cache-navigation-pageshow",
+ ({ windowGlobal }) => this._onNewWindowGlobal(windowGlobal, true)
+ );
+
+ if (watcherActor.sessionContext.type == "browser-element") {
+ const { browsingContext, innerWindowID: innerWindowId } =
+ watcherActor.browserElement;
+ await this._spawnActor(browsingContext.id, innerWindowId);
+ } else if (watcherActor.sessionContext.type == "webextension") {
+ const { addonBrowsingContextID, addonInnerWindowId } =
+ watcherActor.sessionContext;
+ await this._spawnActor(addonBrowsingContextID, addonInnerWindowId);
+ } else if (watcherActor.sessionContext.type == "all") {
+ const parentProcessTargetActor =
+ this.watcherActor.getTargetActorInParentProcess();
+ const { browsingContextID, innerWindowId } =
+ parentProcessTargetActor.form();
+ await this._spawnActor(browsingContextID, innerWindowId);
+ } else {
+ throw new Error(
+ "Unsupported session context type=" + watcherActor.sessionContext.type
+ );
+ }
+ }
+
+ onStoresUpdate(response) {
+ response = getFilteredStorageEvents(response, this.storageKey);
+ if (!response) {
+ return;
+ }
+ this.actor.emit("single-store-update", {
+ changed: response.changed,
+ added: response.added,
+ deleted: response.deleted,
+ });
+ }
+
+ onStoresCleared(response) {
+ const cleared = response[this.storageKey];
+
+ if (!cleared) {
+ return;
+ }
+
+ this.actor.emit("single-store-cleared", {
+ clearedHostsOrPaths: cleared,
+ });
+ }
+
+ destroy() {
+ // Remove observers
+ Services.obs.removeObserver(this, "window-global-created");
+ Services.obs.removeObserver(this, "window-global-destroyed");
+ this._offPageShow();
+ this._cleanActor();
+ }
+
+ async _spawnActor(browsingContextID, innerWindowId) {
+ const storageActor = new StorageActorMock(this.watcherActor);
+ this.storageActor = storageActor;
+ this.actor = new this.ActorConstructor(storageActor);
+
+ // Some storage types require to prelist their stores
+ try {
+ await this.actor.populateStoresForHosts();
+ } catch (e) {
+ // It can happen that the actor gets destroyed while populateStoresForHosts is being
+ // executed.
+ if (this.actor) {
+ throw e;
+ }
+ }
+
+ // If the actor was destroyed, we don't need to go further.
+ if (!this.actor) {
+ return;
+ }
+
+ // We have to manage the actor manually, because ResourceCommand doesn't
+ // use the protocol.js specification.
+ // resource-available-form is typed as "json"
+ // So that we have to manually handle stuff that would normally be
+ // automagically done by procotol.js
+ // 1) Manage the actor in order to have an actorID on it
+ this.watcherActor.manage(this.actor);
+ // 2) Convert to JSON "form"
+ const storage = this.actor.form();
+
+ // All resources should have a resourceType, resourceId and resourceKey
+ // attributes, so available/updated/destroyed callbacks work properly.
+ storage.resourceType = this.storageType;
+ storage.resourceId = `${this.storageType}-${innerWindowId}`;
+ storage.resourceKey = this.storageKey;
+ // NOTE: the resource command needs this attribute
+ storage.browsingContextID = browsingContextID;
+
+ this.onAvailable([storage]);
+
+ // Maps global events from `storageActor` shared for all storage-types,
+ // down to storage-type's specific actor `storage`.
+ storageActor.on("stores-update", this.onStoresUpdate);
+
+ // When a store gets cleared
+ storageActor.on("stores-cleared", this.onStoresCleared);
+ }
+
+ _cleanActor() {
+ this.actor?.destroy();
+ this.actor = null;
+ if (this.storageActor) {
+ this.storageActor.off("stores-update", this.onStoresUpdate);
+ this.storageActor.off("stores-cleared", this.onStoresCleared);
+ this.storageActor.destroy();
+ this.storageActor = null;
+ }
+ }
+
+ /**
+ * Event handler for any docshell update. This lets us figure out whenever
+ * any new window is added, or an existing window is removed.
+ */
+ observe(subject, topic) {
+ if (topic === "window-global-created") {
+ this._onNewWindowGlobal(subject);
+ }
+ }
+
+ /**
+ * Handle WindowGlobal received via:
+ * - <window-global-created> (to cover regular navigations, with brand new documents)
+ * - <bf-cache-navigation-pageshow> (to cover history navications)
+ *
+ * @param {WindowGlobal} windowGlobal
+ * @param {Boolean} isBfCacheNavigation
+ */
+ async _onNewWindowGlobal(windowGlobal, isBfCacheNavigation) {
+ // Only process WindowGlobals which are related to the debugged scope.
+ if (
+ !isWindowGlobalPartOfContext(
+ windowGlobal,
+ this.watcherActor.sessionContext,
+ { acceptNoWindowGlobal: true, acceptSameProcessIframes: true }
+ )
+ ) {
+ return;
+ }
+
+ // Ignore about:blank
+ if (windowGlobal.documentURI.displaySpec === "about:blank") {
+ return;
+ }
+
+ // Only process top BrowsingContext (ignore same-process iframe ones)
+ const isTopContext =
+ windowGlobal.browsingContext.top == windowGlobal.browsingContext;
+ if (!isTopContext) {
+ return;
+ }
+
+ // We only want to spawn a new StorageActor if a new target is being created, i.e.
+ // - target switching is enabled and we're notified about a new top-level window global,
+ // via window-global-created
+ // - target switching is enabled OR bfCacheInParent is enabled, and a bfcache navigation
+ // is performed (See handling of "pageshow" event in DevToolsFrameChild)
+ const isNewTargetBeingCreated =
+ this.watcherActor.sessionContext.isServerTargetSwitchingEnabled ||
+ (isBfCacheNavigation && this.isBfcacheInParentEnabled);
+
+ if (!isNewTargetBeingCreated) {
+ return;
+ }
+
+ // When server side target switching is enabled, we replace the StorageActor
+ // with a new one.
+ // On the frontend, the navigation will destroy the previous target, which
+ // will destroy the previous storage front, so we must notify about a new one.
+
+ // When we are target switching we keep the storage watcher, so we need
+ // to send a new resource to the client.
+ // However, we must ensure that we do this when the new target is
+ // already available, so we check innerWindowId to do it.
+ await new Promise(resolve => {
+ const listener = targetActorForm => {
+ if (targetActorForm.innerWindowId != windowGlobal.innerWindowId) {
+ return;
+ }
+ this.watcherActor.off("target-available-form", listener);
+ resolve();
+ };
+ this.watcherActor.on("target-available-form", listener);
+ });
+
+ this._cleanActor();
+ this._spawnActor(
+ windowGlobal.browsingContext.id,
+ windowGlobal.innerWindowId
+ );
+ }
+}
+
+module.exports = ParentProcessStorage;
+
+class StorageActorMock extends EventEmitter {
+ constructor(watcherActor) {
+ super();
+
+ this.conn = watcherActor.conn;
+ this.watcherActor = watcherActor;
+
+ this.boundUpdate = {};
+
+ // Notifications that help us keep track of newly added windows and windows
+ // that got removed
+ this.observe = this.observe.bind(this);
+ Services.obs.addObserver(this, "window-global-created");
+ Services.obs.addObserver(this, "window-global-destroyed");
+
+ // When doing a bfcache navigation with Fission disabled or with Fission + bfCacheInParent enabled,
+ // we're not getting a the window-global-created/window-global-destroyed events.
+ // In such case, the watcher emits specific events that we can use as equivalent to
+ // window-global-created/window-global-destroyed.
+ // We only need to react to those events here if target switching is not enabled; when
+ // it is enabled, ParentProcessStorage will spawn a whole new actor which will allow
+ // the client to get the information it needs.
+ if (!this.watcherActor.sessionContext.isServerTargetSwitchingEnabled) {
+ this._offPageShow = watcherActor.on(
+ "bf-cache-navigation-pageshow",
+ ({ windowGlobal }) => {
+ // if a new target is created in the content process as a result of the bfcache
+ // navigation, we don't need to emit window-ready as a new StorageActorMock will
+ // be created by ParentProcessStorage.
+ // When server targets are disabled, this only happens when bfcache in parent is enabled.
+ if (this.isBfcacheInParentEnabled) {
+ return;
+ }
+ const windowMock = { location: windowGlobal.documentURI };
+ this.emit("window-ready", windowMock);
+ }
+ );
+
+ this._offPageHide = watcherActor.on(
+ "bf-cache-navigation-pagehide",
+ ({ windowGlobal }) => {
+ const windowMock = { location: windowGlobal.documentURI };
+ // The listener of this events usually check that there are no other windows
+ // with the same host before notifying the client that it can remove it from
+ // the UI. The windows are retrieved from the `windows` getter, and in this case
+ // we still have a reference to the window we're navigating away from.
+ // We pass a `dontCheckHost` parameter alongside the window-destroyed event to
+ // always notify the client.
+ this.emit("window-destroyed", windowMock, { dontCheckHost: true });
+ }
+ );
+ }
+ }
+
+ destroy() {
+ // clear update throttle timeout
+ clearTimeout(this.batchTimer);
+ this.batchTimer = null;
+ // Remove observers
+ Services.obs.removeObserver(this, "window-global-created");
+ Services.obs.removeObserver(this, "window-global-destroyed");
+ if (this._offPageShow) {
+ this._offPageShow();
+ }
+ if (this._offPageHide) {
+ this._offPageHide();
+ }
+ }
+
+ get windows() {
+ return (
+ this.watcherActor
+ .getAllBrowsingContexts({
+ acceptSameProcessIframes: true,
+ })
+ .map(x => {
+ const uri = x.currentWindowGlobal.documentURI;
+ return { location: uri };
+ })
+ // NOTE: we are removing about:blank because we might get them for iframes
+ // whose src attribute has not been set yet.
+ .filter(x => x.location.displaySpec !== "about:blank")
+ );
+ }
+
+ // NOTE: this uri argument is not a real window.Location, but the
+ // `currentWindowGlobal.documentURI` object passed from `windows` getter.
+ getHostName(uri) {
+ switch (uri.scheme) {
+ case "about":
+ case "file":
+ case "javascript":
+ case "resource":
+ return uri.displaySpec;
+ case "moz-extension":
+ case "http":
+ case "https":
+ return uri.prePath;
+ default:
+ // chrome: and data: do not support storage
+ return null;
+ }
+ }
+
+ getWindowFromHost(host) {
+ const hostBrowsingContext = this.watcherActor
+ .getAllBrowsingContexts({ acceptSameProcessIframes: true })
+ .find(x => {
+ const hostName = this.getHostName(x.currentWindowGlobal.documentURI);
+ return hostName === host;
+ });
+ // In case of WebExtension or BrowserToolbox, we may pass privileged hosts
+ // which don't relate to any particular window.
+ // Like "indexeddb+++fx-devtools" or "chrome".
+ // (callsites of this method are used to handle null returned values)
+ if (!hostBrowsingContext) {
+ return null;
+ }
+
+ const principal =
+ hostBrowsingContext.currentWindowGlobal.documentStoragePrincipal;
+
+ return { document: { effectiveStoragePrincipal: principal } };
+ }
+
+ get parentActor() {
+ return {
+ isRootActor: this.watcherActor.sessionContext.type == "all",
+ addonId: this.watcherActor.sessionContext.addonId,
+ };
+ }
+
+ /**
+ * Event handler for any docshell update. This lets us figure out whenever
+ * any new window is added, or an existing window is removed.
+ */
+ async observe(windowGlobal, topic) {
+ // Only process WindowGlobals which are related to the debugged scope.
+ if (
+ !isWindowGlobalPartOfContext(
+ windowGlobal,
+ this.watcherActor.sessionContext,
+ { acceptNoWindowGlobal: true, acceptSameProcessIframes: true }
+ )
+ ) {
+ return;
+ }
+
+ // Ignore about:blank
+ if (windowGlobal.documentURI.displaySpec === "about:blank") {
+ return;
+ }
+
+ // Only notify about remote iframe windows when JSWindowActor based targets are enabled
+ // We will create a new StorageActor for the top level tab documents when server side target
+ // switching is enabled
+ const isTopContext =
+ windowGlobal.browsingContext.top == windowGlobal.browsingContext;
+ if (
+ isTopContext &&
+ this.watcherActor.sessionContext.isServerTargetSwitchingEnabled
+ ) {
+ return;
+ }
+
+ // emit window-wready and window-destroyed events when needed
+ const windowMock = { location: windowGlobal.documentURI };
+ if (topic === "window-global-created") {
+ this.emit("window-ready", windowMock);
+ } else if (topic === "window-global-destroyed") {
+ this.emit("window-destroyed", windowMock);
+ }
+ }
+
+ /**
+ * This method is called by the registered storage types so as to tell the
+ * Storage Actor that there are some changes in the stores. Storage Actor then
+ * notifies the client front about these changes at regular (BATCH_DELAY)
+ * interval.
+ *
+ * @param {string} action
+ * The type of change. One of "added", "changed" or "deleted"
+ * @param {string} storeType
+ * The storage actor in which this change has occurred.
+ * @param {object} data
+ * The update object. This object is of the following format:
+ * - {
+ * <host1>: [<store_names1>, <store_name2>...],
+ * <host2>: [<store_names34>...],
+ * }
+ * Where host1, host2 are the host in which this change happened and
+ * [<store_namesX] is an array of the names of the changed store objects.
+ * Pass an empty array if the host itself was affected: either completely
+ * removed or cleared.
+ */
+ // eslint-disable-next-line complexity
+ update(action, storeType, data) {
+ if (action == "cleared") {
+ this.emit("stores-cleared", { [storeType]: data });
+ return null;
+ }
+
+ if (this.batchTimer) {
+ clearTimeout(this.batchTimer);
+ }
+ if (!this.boundUpdate[action]) {
+ this.boundUpdate[action] = {};
+ }
+ if (!this.boundUpdate[action][storeType]) {
+ this.boundUpdate[action][storeType] = {};
+ }
+ for (const host in data) {
+ if (!this.boundUpdate[action][storeType][host]) {
+ this.boundUpdate[action][storeType][host] = [];
+ }
+ for (const name of data[host]) {
+ if (!this.boundUpdate[action][storeType][host].includes(name)) {
+ this.boundUpdate[action][storeType][host].push(name);
+ }
+ }
+ }
+ if (action == "added") {
+ // If the same store name was previously deleted or changed, but now is
+ // added somehow, dont send the deleted or changed update.
+ this.removeNamesFromUpdateList("deleted", storeType, data);
+ this.removeNamesFromUpdateList("changed", storeType, data);
+ } else if (
+ action == "changed" &&
+ this.boundUpdate.added &&
+ this.boundUpdate.added[storeType]
+ ) {
+ // If something got added and changed at the same time, then remove those
+ // items from changed instead.
+ this.removeNamesFromUpdateList(
+ "changed",
+ storeType,
+ this.boundUpdate.added[storeType]
+ );
+ } else if (action == "deleted") {
+ // If any item got delete, or a host got delete, no point in sending
+ // added or changed update
+ this.removeNamesFromUpdateList("added", storeType, data);
+ this.removeNamesFromUpdateList("changed", storeType, data);
+
+ for (const host in data) {
+ if (
+ !data[host].length &&
+ this.boundUpdate.added &&
+ this.boundUpdate.added[storeType] &&
+ this.boundUpdate.added[storeType][host]
+ ) {
+ delete this.boundUpdate.added[storeType][host];
+ }
+ if (
+ !data[host].length &&
+ this.boundUpdate.changed &&
+ this.boundUpdate.changed[storeType] &&
+ this.boundUpdate.changed[storeType][host]
+ ) {
+ delete this.boundUpdate.changed[storeType][host];
+ }
+ }
+ }
+
+ this.batchTimer = setTimeout(() => {
+ clearTimeout(this.batchTimer);
+ this.emit("stores-update", this.boundUpdate);
+ this.boundUpdate = {};
+ }, BATCH_DELAY);
+
+ return null;
+ }
+
+ /**
+ * This method removes data from the this.boundUpdate object in the same
+ * manner like this.update() adds data to it.
+ *
+ * @param {string} action
+ * The type of change. One of "added", "changed" or "deleted"
+ * @param {string} storeType
+ * The storage actor for which you want to remove the updates data.
+ * @param {object} data
+ * The update object. This object is of the following format:
+ * - {
+ * <host1>: [<store_names1>, <store_name2>...],
+ * <host2>: [<store_names34>...],
+ * }
+ * Where host1, host2 are the hosts which you want to remove and
+ * [<store_namesX] is an array of the names of the store objects.
+ */
+ removeNamesFromUpdateList(action, storeType, data) {
+ for (const host in data) {
+ if (
+ this.boundUpdate[action] &&
+ this.boundUpdate[action][storeType] &&
+ this.boundUpdate[action][storeType][host]
+ ) {
+ for (const name of data[host]) {
+ const index = this.boundUpdate[action][storeType][host].indexOf(name);
+ if (index > -1) {
+ this.boundUpdate[action][storeType][host].splice(index, 1);
+ }
+ }
+ if (!this.boundUpdate[action][storeType][host].length) {
+ delete this.boundUpdate[action][storeType][host];
+ }
+ }
+ }
+ return null;
+ }
+}
diff --git a/devtools/server/actors/resources/websockets.js b/devtools/server/actors/resources/websockets.js
new file mode 100644
index 0000000000..5845357a9c
--- /dev/null
+++ b/devtools/server/actors/resources/websockets.js
@@ -0,0 +1,196 @@
+/* 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 {
+ LongStringActor,
+} = require("resource://devtools/server/actors/string.js");
+
+const {
+ TYPES: { WEBSOCKET },
+} = require("resource://devtools/server/actors/resources/index.js");
+
+const webSocketEventService = Cc[
+ "@mozilla.org/websocketevent/service;1"
+].getService(Ci.nsIWebSocketEventService);
+
+class WebSocketWatcher {
+ constructor() {
+ this.windowIds = new Set();
+ // Maintains a map of all the connection channels per websocket
+ // The map item is keyed on the `webSocketSerialID` and stores
+ // the `httpChannelId` as value.
+ this.connections = new Map();
+ this.onWindowReady = this.onWindowReady.bind(this);
+ this.onWindowDestroy = this.onWindowDestroy.bind(this);
+ }
+
+ static createResource(wsMessageType, eventParams) {
+ return {
+ resourceType: WEBSOCKET,
+ wsMessageType,
+ ...eventParams,
+ };
+ }
+
+ static prepareFramePayload(targetActor, frame) {
+ const payload = new LongStringActor(targetActor.conn, frame.payload);
+ targetActor.manage(payload);
+ return payload.form();
+ }
+
+ watch(targetActor, { onAvailable }) {
+ this.targetActor = targetActor;
+ this.onAvailable = onAvailable;
+
+ for (const window of this.targetActor.windows) {
+ const { innerWindowId } = window.windowGlobalChild;
+ this.startListening(innerWindowId);
+ }
+
+ // On navigate/reload we should re-start listening with the
+ // new `innerWindowID`
+ this.targetActor.on("window-ready", this.onWindowReady);
+ this.targetActor.on("window-destroyed", this.onWindowDestroy);
+ }
+
+ onWindowReady({ window }) {
+ if (!this.targetActor.followWindowGlobalLifeCycle) {
+ const { innerWindowId } = window.windowGlobalChild;
+ this.startListening(innerWindowId);
+ }
+ }
+
+ onWindowDestroy({ id }) {
+ this.stopListening(id);
+ }
+
+ startListening(innerWindowId) {
+ if (!this.windowIds.has(innerWindowId)) {
+ this.windowIds.add(innerWindowId);
+ webSocketEventService.addListener(innerWindowId, this);
+ }
+ }
+
+ stopListening(innerWindowId) {
+ if (this.windowIds.has(innerWindowId)) {
+ this.windowIds.delete(innerWindowId);
+ if (!webSocketEventService.hasListenerFor(innerWindowId)) {
+ // The listener might have already been cleaned up on `window-destroy`.
+ console.warn(
+ "Already stopped listening to websocket events for this window."
+ );
+ return;
+ }
+ webSocketEventService.removeListener(innerWindowId, this);
+ }
+ }
+
+ destroy() {
+ for (const id of this.windowIds) {
+ this.stopListening(id);
+ }
+ this.targetActor.off("window-ready", this.onWindowReady);
+ this.targetActor.off("window-destroyed", this.onWindowDestroy);
+ }
+
+ // methods for the nsIWebSocketEventService
+ webSocketCreated(webSocketSerialID, uri, protocols) {}
+
+ webSocketOpened(
+ webSocketSerialID,
+ effectiveURI,
+ protocols,
+ extensions,
+ httpChannelId
+ ) {
+ this.connections.set(webSocketSerialID, httpChannelId);
+ const resource = WebSocketWatcher.createResource("webSocketOpened", {
+ httpChannelId,
+ effectiveURI,
+ protocols,
+ extensions,
+ });
+
+ this.onAvailable([resource]);
+ }
+
+ webSocketMessageAvailable(webSocketSerialID, data, messageType) {}
+
+ webSocketClosed(webSocketSerialID, wasClean, code, reason) {
+ const httpChannelId = this.connections.get(webSocketSerialID);
+ this.connections.delete(webSocketSerialID);
+
+ const resource = WebSocketWatcher.createResource("webSocketClosed", {
+ httpChannelId,
+ wasClean,
+ code,
+ reason,
+ });
+
+ this.onAvailable([resource]);
+ }
+
+ frameReceived(webSocketSerialID, frame) {
+ const httpChannelId = this.connections.get(webSocketSerialID);
+ if (!httpChannelId) {
+ return;
+ }
+
+ const payload = WebSocketWatcher.prepareFramePayload(
+ this.targetActor,
+ frame
+ );
+ const resource = WebSocketWatcher.createResource("frameReceived", {
+ httpChannelId,
+ data: {
+ type: "received",
+ payload,
+ timeStamp: frame.timeStamp,
+ finBit: frame.finBit,
+ rsvBit1: frame.rsvBit1,
+ rsvBit2: frame.rsvBit2,
+ rsvBit3: frame.rsvBit3,
+ opCode: frame.opCode,
+ mask: frame.mask,
+ maskBit: frame.maskBit,
+ },
+ });
+
+ this.onAvailable([resource]);
+ }
+
+ frameSent(webSocketSerialID, frame) {
+ const httpChannelId = this.connections.get(webSocketSerialID);
+
+ if (!httpChannelId) {
+ return;
+ }
+
+ const payload = WebSocketWatcher.prepareFramePayload(
+ this.targetActor,
+ frame
+ );
+ const resource = WebSocketWatcher.createResource("frameSent", {
+ httpChannelId,
+ data: {
+ type: "sent",
+ payload,
+ timeStamp: frame.timeStamp,
+ finBit: frame.finBit,
+ rsvBit1: frame.rsvBit1,
+ rsvBit2: frame.rsvBit2,
+ rsvBit3: frame.rsvBit3,
+ opCode: frame.opCode,
+ mask: frame.mask,
+ maskBit: frame.maskBit,
+ },
+ });
+
+ this.onAvailable([resource]);
+ }
+}
+
+module.exports = WebSocketWatcher;