Source code

Revision control

Copy as Markdown

Other Tools

/* 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 {
ACTIVITY_TYPE,
EVENTS,
TEST_EVENTS,
} = require("resource://devtools/client/netmonitor/src/constants.js");
const FirefoxDataProvider = require("resource://devtools/client/netmonitor/src/connector/firefox-data-provider.js");
const {
getDisplayedTimingMarker,
} = require("resource://devtools/client/netmonitor/src/selectors/index.js");
const {
TYPES,
} = require("resource://devtools/shared/commands/resource/resource-command.js");
// Network throttling
loader.lazyRequireGetter(
this,
"throttlingProfiles",
"resource://devtools/client/shared/components/throttling/profiles.js"
);
loader.lazyRequireGetter(
this,
"HarMetadataCollector",
"resource://devtools/client/netmonitor/src/connector/har-metadata-collector.js",
true
);
const DEVTOOLS_ENABLE_PERSISTENT_LOG_PREF = "devtools.netmonitor.persistlog";
/**
* Connector to Firefox backend.
*/
class Connector {
constructor() {
// Public methods
this.connect = this.connect.bind(this);
this.disconnect = this.disconnect.bind(this);
this.willNavigate = this.willNavigate.bind(this);
this.navigate = this.navigate.bind(this);
this.triggerActivity = this.triggerActivity.bind(this);
this.viewSourceInDebugger = this.viewSourceInDebugger.bind(this);
this.requestData = this.requestData.bind(this);
this.getTimingMarker = this.getTimingMarker.bind(this);
this.updateNetworkThrottling = this.updateNetworkThrottling.bind(this);
// Internals
this.getLongString = this.getLongString.bind(this);
this.onResourceAvailable = this.onResourceAvailable.bind(this);
this.onResourceUpdated = this.onResourceUpdated.bind(this);
this.updatePersist = this.updatePersist.bind(this);
this.networkFront = null;
}
static NETWORK_RESOURCES = [
TYPES.NETWORK_EVENT,
TYPES.NETWORK_EVENT_STACKTRACE,
TYPES.WEBSOCKET,
TYPES.SERVER_SENT_EVENT,
];
get currentTarget() {
return this.commands.targetCommand.targetFront;
}
/**
* Connect to the backend.
*
* @param {Object} connection object with e.g. reference to the Toolbox.
* @param {Object} actions (optional) is used to fire Redux actions to update store.
* @param {Object} getState (optional) is used to get access to the state.
*/
async connect(connection, actions, getState) {
this.actions = actions;
this.getState = getState;
this.toolbox = connection.toolbox;
this.commands = this.toolbox.commands;
this.networkCommand = this.commands.networkCommand;
// The owner object (NetMonitorAPI) received all events.
this.owner = connection.owner;
this.networkFront =
await this.commands.watcherFront.getNetworkParentActor();
this.dataProvider = new FirefoxDataProvider({
commands: this.commands,
actions: this.actions,
owner: this.owner,
});
this._harMetadataCollector = new HarMetadataCollector(this.commands);
await this._harMetadataCollector.connect();
await this.commands.resourceCommand.watchResources([TYPES.DOCUMENT_EVENT], {
onAvailable: this.onResourceAvailable,
});
await this.resume(false);
// Server side persistance of the data across reload is disabled by default.
// Ensure enabling it, if the related frontend pref is true.
if (Services.prefs.getBoolPref(DEVTOOLS_ENABLE_PERSISTENT_LOG_PREF)) {
await this.updatePersist();
}
Services.prefs.addObserver(
DEVTOOLS_ENABLE_PERSISTENT_LOG_PREF,
this.updatePersist
);
}
disconnect() {
// As this function might be called twice, we need to guard if already called.
if (this._destroyed) {
return;
}
this._destroyed = true;
this.commands.resourceCommand.unwatchResources([TYPES.DOCUMENT_EVENT], {
onAvailable: this.onResourceAvailable,
});
this.pause();
Services.prefs.removeObserver(
DEVTOOLS_ENABLE_PERSISTENT_LOG_PREF,
this.updatePersist
);
if (this.actions) {
this.actions.batchReset();
}
this.dataProvider.destroy();
this.dataProvider = null;
this._harMetadataCollector.destroy();
}
/**
* Clear network data from the connector.
*
* @param {object} options
* @param {boolean} options.isExplicitClear
* Set to true if the call to clear requests is explicitly requested by
* the user, to false if this is an automated clear, eg on navigation.
*
*/
clear({ isExplicitClear }) {
// Clear all the caches in the data provider
this.dataProvider.clear();
this._harMetadataCollector.clear();
if (isExplicitClear) {
// Only clear the resources if the clear was initiated explicitly by the
// UI, in other cases (eg navigation) the server handles the cleanup.
this.commands.resourceCommand.clearResources(Connector.NETWORK_RESOURCES);
this.emitForTests("clear-network-resources");
}
// Disable the related network logs in the webconsole
this.toolbox.disableAllConsoleNetworkLogs();
}
pause() {
return this.commands.resourceCommand.unwatchResources(
Connector.NETWORK_RESOURCES,
{
onAvailable: this.onResourceAvailable,
onUpdated: this.onResourceUpdated,
}
);
}
resume(ignoreExistingResources = true) {
return this.commands.resourceCommand.watchResources(
Connector.NETWORK_RESOURCES,
{
onAvailable: this.onResourceAvailable,
onUpdated: this.onResourceUpdated,
ignoreExistingResources,
}
);
}
async onResourceAvailable(resources, { areExistingResources }) {
for (const resource of resources) {
if (resource.resourceType === TYPES.DOCUMENT_EVENT) {
this.onDocEvent(resource, { areExistingResources });
continue;
}
if (resource.resourceType === TYPES.NETWORK_EVENT) {
this.dataProvider.onNetworkResourceAvailable(resource);
continue;
}
if (resource.resourceType === TYPES.NETWORK_EVENT_STACKTRACE) {
this.dataProvider.onStackTraceAvailable(resource);
continue;
}
if (resource.resourceType === TYPES.WEBSOCKET) {
const { wsMessageType } = resource;
switch (wsMessageType) {
case "webSocketOpened": {
this.dataProvider.onWebSocketOpened(
resource.httpChannelId,
resource.effectiveURI,
resource.protocols,
resource.extensions
);
break;
}
case "webSocketClosed": {
this.dataProvider.onWebSocketClosed(
resource.httpChannelId,
resource.wasClean,
resource.code,
resource.reason
);
break;
}
case "frameReceived": {
this.dataProvider.onFrameReceived(
resource.httpChannelId,
resource.data
);
break;
}
case "frameSent": {
this.dataProvider.onFrameSent(
resource.httpChannelId,
resource.data
);
break;
}
}
continue;
}
if (resource.resourceType === TYPES.SERVER_SENT_EVENT) {
const { messageType, httpChannelId, data } = resource;
switch (messageType) {
case "eventSourceConnectionClosed": {
this.dataProvider.onEventSourceConnectionClosed(httpChannelId);
break;
}
case "eventReceived": {
this.dataProvider.onEventReceived(httpChannelId, data);
break;
}
}
}
}
}
async onResourceUpdated(updates) {
for (const { resource, update } of updates) {
this.dataProvider.onNetworkResourceUpdated(resource, update);
}
}
enableActions(enable) {
this.dataProvider.enableActions(enable);
}
willNavigate() {
if (this.actions) {
if (!Services.prefs.getBoolPref(DEVTOOLS_ENABLE_PERSISTENT_LOG_PREF)) {
this.actions.batchReset();
this.actions.clearRequests({ isExplicitClear: false });
} else {
// If the log is persistent, just clear all accumulated timing markers.
this.actions.clearTimingMarkers();
}
}
if (this.actions && this.getState) {
const state = this.getState();
// Resume is done automatically on page reload/navigation.
if (!state.requests.recording) {
this.actions.toggleRecording();
}
// Stop any ongoing search.
if (state.search.ongoingSearch) {
this.actions.stopOngoingSearch();
}
}
}
navigate() {
if (!this.dataProvider.hasPendingRequests()) {
this.onReloaded();
return;
}
const listener = () => {
if (this.dataProvider && this.dataProvider.hasPendingRequests()) {
return;
}
if (this.owner) {
this.owner.off(EVENTS.PAYLOAD_READY, listener);
}
// Netmonitor may already be destroyed,
// so do not try to notify the listeners
if (this.dataProvider) {
this.onReloaded();
}
};
if (this.owner) {
this.owner.on(EVENTS.PAYLOAD_READY, listener);
}
}
onReloaded() {
const panel = this.toolbox.getPanel("netmonitor");
if (panel) {
panel.emit("reloaded");
}
}
/**
* The "DOMContentLoaded" and "Load" events sent by the console actor.
*
* @param {object} resource The DOCUMENT_EVENT resource
*/
onDocEvent(resource, { areExistingResources }) {
if (!resource.targetFront.isTopLevel) {
// Only consider top level document, and ignore remote iframes top document
return;
}
// Netmonitor does not support dom-loading
if (
resource.name != "dom-interactive" &&
resource.name != "dom-complete" &&
resource.name != "will-navigate"
) {
return;
}
if (resource.name == "will-navigate") {
// When we open the netmonitor while the page already started loading,
// we don't want to clear it. So here, we ignore will-navigate events
// which were stored in the ResourceCommand cache and only consider
// the live one coming straight from the server.
if (!areExistingResources) {
this.willNavigate();
}
return;
}
if (this.actions) {
this.actions.addTimingMarker(resource);
}
if (resource.name === "dom-complete") {
this.navigate();
}
this.emitForTests(TEST_EVENTS.TIMELINE_EVENT, resource);
}
async updatePersist() {
const enabled = Services.prefs.getBoolPref(
DEVTOOLS_ENABLE_PERSISTENT_LOG_PREF
);
await this.networkFront.setPersist(enabled);
this.emitForTests(TEST_EVENTS.PERSIST_CHANGED, enabled);
}
/**
* Triggers a specific "activity" to be performed by the frontend.
* This can be, for example, triggering reloads or enabling/disabling cache.
*
* @param {number} type The activity type. See the ACTIVITY_TYPE const.
* @return {object} A promise resolved once the activity finishes and the frontend
* is back into "standby" mode.
*/
triggerActivity(type) {
// Puts the frontend into "standby" (when there's no particular activity).
const standBy = () => {
this.currentActivity = ACTIVITY_TYPE.NONE;
};
// Reconfigures the tab, optionally triggering a reload.
const reconfigureTab = async options => {
await this.commands.targetConfigurationCommand.updateConfiguration(
options
);
};
// Reconfigures the tab and waits for the target to finish navigating.
const reconfigureTabAndReload = async options => {
await reconfigureTab(options);
await this.commands.targetCommand.reloadTopLevelTarget();
};
switch (type) {
case ACTIVITY_TYPE.RELOAD.WITH_CACHE_DEFAULT:
return reconfigureTabAndReload({}).then(standBy);
case ACTIVITY_TYPE.RELOAD.WITH_CACHE_ENABLED:
this.currentActivity = ACTIVITY_TYPE.ENABLE_CACHE;
this.commands.resourceCommand
.waitForNextResource(
this.commands.resourceCommand.TYPES.DOCUMENT_EVENT,
{
ignoreExistingResources: true,
predicate(resource) {
return resource.name == "will-navigate";
},
}
)
.then(() => {
this.currentActivity = type;
});
return reconfigureTabAndReload({
cacheDisabled: false,
}).then(standBy);
case ACTIVITY_TYPE.RELOAD.WITH_CACHE_DISABLED:
this.currentActivity = ACTIVITY_TYPE.DISABLE_CACHE;
this.commands.resourceCommand
.waitForNextResource(
this.commands.resourceCommand.TYPES.DOCUMENT_EVENT,
{
ignoreExistingResources: true,
predicate(resource) {
return resource.name == "will-navigate";
},
}
)
.then(() => {
this.currentActivity = type;
});
return reconfigureTabAndReload({
cacheDisabled: true,
}).then(standBy);
case ACTIVITY_TYPE.ENABLE_CACHE:
this.currentActivity = type;
return reconfigureTab({
cacheDisabled: false,
}).then(standBy);
case ACTIVITY_TYPE.DISABLE_CACHE:
this.currentActivity = type;
return reconfigureTab({
cacheDisabled: true,
}).then(standBy);
}
this.currentActivity = ACTIVITY_TYPE.NONE;
return Promise.reject(new Error("Invalid activity type"));
}
/**
* Fetches the full text of a LongString.
*
* @param {object|string} stringGrip
* The long string grip containing the corresponding actor.
* If you pass in a plain string (by accident or because you're lazy),
* then a promise of the same string is simply returned.
* @return {object}
* A promise that is resolved when the full string contents
* are available, or rejected if something goes wrong.
*/
getLongString(stringGrip) {
return this.dataProvider.getLongString(stringGrip);
}
/**
* Used for HAR generation.
*/
getHarData() {
return this._harMetadataCollector.getHarData();
}
/**
* Getter that returns the current toolbox instance.
* @return {Toolbox} toolbox instance
*/
getToolbox() {
return this.toolbox;
}
/**
* Open a given source in Debugger
* @param {string} sourceURL source url
* @param {number} sourceLine source line number
*/
viewSourceInDebugger(sourceURL, sourceLine, sourceColumn) {
if (this.toolbox) {
this.toolbox.viewSourceInDebugger(sourceURL, sourceLine, sourceColumn);
}
}
/**
* Fetch networkEventUpdate websocket message from back-end when
* data provider is connected.
* @param {object} request network request instance
* @param {string} type NetworkEventUpdate type
*/
requestData(request, type) {
return this.dataProvider.requestData(request, type);
}
getTimingMarker(name) {
if (!this.getState) {
return -1;
}
const state = this.getState();
return getDisplayedTimingMarker(state, name);
}
async updateNetworkThrottling(enabled, profile) {
if (!enabled) {
this.networkFront.clearNetworkThrottling();
await this.commands.targetConfigurationCommand.updateConfiguration({
setTabOffline: false,
});
} else {
// The profile can be either a profile id which is used to
// search the predefined throttle profiles or a profile object
// as defined in the trottle tests.
if (typeof profile === "string") {
profile = throttlingProfiles.profiles.find(({ id }) => id == profile);
}
const { download, upload, latency, id } = profile;
// The offline profile has download and upload set to false
await this.commands.targetConfigurationCommand.updateConfiguration({
setTabOffline: id === throttlingProfiles.PROFILE_CONSTANTS.OFFLINE,
});
await this.networkFront.setNetworkThrottling({
downloadThroughput: download,
uploadThroughput: upload,
latency,
});
}
this.emitForTests(TEST_EVENTS.THROTTLING_CHANGED, { profile });
}
/**
* Fire events for the owner object. These events are only
* used in tests so, don't fire them in production release.
*/
emitForTests(type, data) {
if (this.owner) {
this.owner.emitForTests(type, data);
}
}
}
module.exports.Connector = Connector;