diff options
Diffstat (limited to 'devtools/client/netmonitor/src/connector/firefox-data-provider.js')
-rw-r--r-- | devtools/client/netmonitor/src/connector/firefox-data-provider.js | 832 |
1 files changed, 832 insertions, 0 deletions
diff --git a/devtools/client/netmonitor/src/connector/firefox-data-provider.js b/devtools/client/netmonitor/src/connector/firefox-data-provider.js new file mode 100644 index 0000000000..9cdf6fc1d7 --- /dev/null +++ b/devtools/client/netmonitor/src/connector/firefox-data-provider.js @@ -0,0 +1,832 @@ +/* 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/. */ +/* eslint-disable block-scoped-var */ + +"use strict"; + +const { + EVENTS, + TEST_EVENTS, +} = require("resource://devtools/client/netmonitor/src/constants.js"); +const { CurlUtils } = require("resource://devtools/client/shared/curl.js"); +const { + fetchHeaders, +} = require("resource://devtools/client/netmonitor/src/utils/request-utils.js"); + +const { + getLongStringFullText, +} = require("resource://devtools/client/shared/string-utils.js"); + +/** + * This object is responsible for fetching additional HTTP + * data from the backend over RDP protocol. + * + * The object also keeps track of RDP requests in-progress, + * so it's possible to determine whether all has been fetched + * or not. + */ +class FirefoxDataProvider { + /** + * Constructor for data provider + * + * @param {Object} commands Object defined from devtools/shared/commands to interact with the devtools backend + * @param {Object} actions set of actions fired during data fetching process. + * @param {Object} owner all events are fired on this object. + */ + constructor({ commands, actions, owner }) { + // Options + this.commands = commands; + this.actions = actions || {}; + this.actionsEnabled = true; + + // Allow requesting of on-demand network data, this would be `false` when requests + // are cleared (as we clear also on the backend), and will be flipped back + // to true on the next `onNetworkResourceAvailable` call. + this._requestDataEnabled = true; + + // `_requestDataEnabled` can only be used to prevent new calls to + // requestData. For pending/already started calls, we need to check if + // clear() was called during the call, which is the purpose of this counter. + this._lastRequestDataClearId = 0; + + this.owner = owner; + + // This holds stacktraces infomation temporarily. Stacktrace resources + // can come before or after (out of order) their related network events. + // This will hold stacktrace related info from the NETWORK_EVENT_STACKTRACE resource + // for the NETWORK_EVENT resource and vice versa. + this.stackTraces = new Map(); + // Map of the stacktrace information keyed by the actor id's + this.stackTraceRequestInfoByActorID = new Map(); + + // For tracking unfinished requests + this.pendingRequests = new Set(); + + // Map[key string => Promise] used by `requestData` to prevent requesting the same + // request data twice. + this.lazyRequestData = new Map(); + + // Fetching data from the backend + this.getLongString = this.getLongString.bind(this); + + // Event handlers + this.onNetworkResourceAvailable = + this.onNetworkResourceAvailable.bind(this); + this.onNetworkResourceUpdated = this.onNetworkResourceUpdated.bind(this); + + this.onWebSocketOpened = this.onWebSocketOpened.bind(this); + this.onWebSocketClosed = this.onWebSocketClosed.bind(this); + this.onFrameSent = this.onFrameSent.bind(this); + this.onFrameReceived = this.onFrameReceived.bind(this); + + this.onEventSourceConnectionClosed = + this.onEventSourceConnectionClosed.bind(this); + this.onEventReceived = this.onEventReceived.bind(this); + this.setEventStreamFlag = this.setEventStreamFlag.bind(this); + } + + /* + * Cleans up all the internal states, this usually done before navigation + * (without the persist flag on). + */ + clear() { + this.stackTraces.clear(); + this.pendingRequests.clear(); + this.lazyRequestData.clear(); + this.stackTraceRequestInfoByActorID.clear(); + this._requestDataEnabled = false; + this._lastRequestDataClearId++; + } + + destroy() { + // TODO: clear() is called in the middle of the lifecycle of the + // FirefoxDataProvider, for clarity we are exposing it as a separate method. + // `destroy` should be updated to nullify relevant instance properties. + this.clear(); + } + + /** + * Enable/disable firing redux actions (enabled by default). + * + * @param {boolean} enable Set to true to fire actions. + */ + enableActions(enable) { + this.actionsEnabled = enable; + } + + /** + * Add a new network request to application state. + * + * @param {string} id request id + * @param {object} resource resource payload will be added to application state + */ + async addRequest(id, resource) { + // Add to the pending requests which helps when deciding if the request is complete. + this.pendingRequests.add(id); + + if (this.actionsEnabled && this.actions.addRequest) { + await this.actions.addRequest(id, resource, true); + } + + this.emit(EVENTS.REQUEST_ADDED, id); + } + + /** + * Update a network request if it already exists in application state. + * + * @param {string} id request id + * @param {object} data data payload will be updated to application state + */ + async updateRequest(id, data) { + const { + responseContent, + responseCookies, + responseHeaders, + requestCookies, + requestHeaders, + requestPostData, + responseCache, + } = data; + + // fetch request detail contents in parallel + const [ + responseContentObj, + requestHeadersObj, + responseHeadersObj, + postDataObj, + requestCookiesObj, + responseCookiesObj, + responseCacheObj, + ] = await Promise.all([ + this.fetchResponseContent(responseContent), + this.fetchRequestHeaders(requestHeaders), + this.fetchResponseHeaders(responseHeaders), + this.fetchPostData(requestPostData), + this.fetchRequestCookies(requestCookies), + this.fetchResponseCookies(responseCookies), + this.fetchResponseCache(responseCache), + ]); + + const payload = Object.assign( + {}, + data, + responseContentObj, + requestHeadersObj, + responseHeadersObj, + postDataObj, + requestCookiesObj, + responseCookiesObj, + responseCacheObj + ); + + if (this.actionsEnabled && this.actions.updateRequest) { + await this.actions.updateRequest(id, payload, true); + } + + return payload; + } + + async fetchResponseContent(responseContent) { + const payload = {}; + if (responseContent?.content) { + const { text } = responseContent.content; + const response = await this.getLongString(text); + responseContent.content.text = response; + payload.responseContent = responseContent; + } + return payload; + } + + async fetchRequestHeaders(requestHeaders) { + const payload = {}; + if (requestHeaders?.headers?.length) { + const headers = await fetchHeaders(requestHeaders, this.getLongString); + if (headers) { + payload.requestHeaders = headers; + } + } + return payload; + } + + async fetchResponseHeaders(responseHeaders) { + const payload = {}; + if (responseHeaders?.headers?.length) { + const headers = await fetchHeaders(responseHeaders, this.getLongString); + if (headers) { + payload.responseHeaders = headers; + } + } + return payload; + } + + async fetchPostData(requestPostData) { + const payload = {}; + if (requestPostData?.postData) { + const { text } = requestPostData.postData; + const postData = await this.getLongString(text); + const headers = CurlUtils.getHeadersFromMultipartText(postData); + + // Calculate total header size and don't forget to include + // two new-line characters at the end. + const headersSize = headers.reduce((acc, { name, value }) => { + return acc + name.length + value.length + 2; + }, 0); + + requestPostData.postData.text = postData; + payload.requestPostData = { + ...requestPostData, + uploadHeaders: { headers, headersSize }, + }; + } + return payload; + } + + async fetchRequestCookies(requestCookies) { + const payload = {}; + if (requestCookies) { + const reqCookies = []; + // request store cookies in requestCookies or requestCookies.cookies + const cookies = requestCookies.cookies + ? requestCookies.cookies + : requestCookies; + // make sure cookies is iterable + if (typeof cookies[Symbol.iterator] === "function") { + for (const cookie of cookies) { + reqCookies.push( + Object.assign({}, cookie, { + value: await this.getLongString(cookie.value), + }) + ); + } + if (reqCookies.length) { + payload.requestCookies = reqCookies; + } + } + } + return payload; + } + + async fetchResponseCookies(responseCookies) { + const payload = {}; + if (responseCookies) { + const resCookies = []; + // response store cookies in responseCookies or responseCookies.cookies + const cookies = responseCookies.cookies + ? responseCookies.cookies + : responseCookies; + // make sure cookies is iterable + if (typeof cookies[Symbol.iterator] === "function") { + for (const cookie of cookies) { + resCookies.push( + Object.assign({}, cookie, { + value: await this.getLongString(cookie.value), + }) + ); + } + if (resCookies.length) { + payload.responseCookies = resCookies; + } + } + } + return payload; + } + + async fetchResponseCache(responseCache) { + const payload = {}; + if (responseCache) { + payload.responseCache = await responseCache; + payload.responseCacheAvailable = false; + } + return payload; + } + + /** + * Public API used by the Toolbox: Tells if there is still any pending request. + * + * @return {boolean} returns true if pending requests still exist in the queue. + */ + hasPendingRequests() { + return this.pendingRequests.size > 0; + } + + /** + * 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. + */ + async getLongString(stringGrip) { + const payload = await getLongStringFullText( + this.commands.client, + stringGrip + ); + this.emitForTests(TEST_EVENTS.LONGSTRING_RESOLVED, { payload }); + return payload; + } + + /** + * Retrieve the stack-trace information for the given StackTracesActor. + * + * @param object actor + * - {Object} targetFront: the target front. + * + * - {String} resourceId: the resource id for the network request". + * @return {object} + */ + async _getStackTraceFromWatcher(actor) { + // If we request the stack trace for the navigation request, + // t was coming from previous page content process, which may no longer be around. + // In any case, the previous target is destroyed and we can't fetch the stack anymore. + let stacktrace = []; + if (!actor.targetFront.isDestroyed()) { + const networkContentFront = await actor.targetFront.getFront( + "networkContent" + ); + stacktrace = await networkContentFront.getStackTrace( + actor.stacktraceResourceId + ); + } + return { stacktrace }; + } + + /** + * The handler for when the network event stacktrace resource is available. + * The resource contains basic info, the actual stacktrace is fetched lazily + * using requestData. + * @param {object} resource The network event stacktrace resource + */ + async onStackTraceAvailable(resource) { + if (!this.stackTraces.has(resource.resourceId)) { + // If no stacktrace info exists, this means the network event + // has not fired yet, lets store useful info for the NETWORK_EVENT + // resource. + this.stackTraces.set(resource.resourceId, resource); + } else { + // If stacktrace info exists, this means the network event has already + // fired, so lets just update the reducer with the necessary stacktrace info. + const request = this.stackTraces.get(resource.resourceId); + request.cause.stacktraceAvailable = resource.stacktraceAvailable; + request.cause.lastFrame = resource.lastFrame; + + this.stackTraces.delete(resource.resourceId); + + this.stackTraceRequestInfoByActorID.set(request.actor, { + targetFront: resource.targetFront, + stacktraceResourceId: resource.resourceId, + }); + + if (this.actionsEnabled && this.actions.updateRequest) { + await this.actions.updateRequest(request.actor, request, true); + } + } + } + + /** + * The handler for when the network event resource is available. + * + * @param {object} resource The network event resource + */ + async onNetworkResourceAvailable(resource) { + const { actor, stacktraceResourceId, cause } = resource; + + if (!this._requestDataEnabled) { + this._requestDataEnabled = true; + } + + // Check if a stacktrace resource already exists for this network resource. + if (this.stackTraces.has(stacktraceResourceId)) { + const { stacktraceAvailable, lastFrame, targetFront } = + this.stackTraces.get(stacktraceResourceId); + + resource.cause.stacktraceAvailable = stacktraceAvailable; + resource.cause.lastFrame = lastFrame; + + this.stackTraces.delete(stacktraceResourceId); + // We retrieve preliminary information about the stacktrace from the + // NETWORK_EVENT_STACKTRACE resource via `this.stackTraces` Map, + // The actual stacktrace is fetched lazily based on the actor id, using + // the targetFront and the stacktrace resource id therefore we + // map these for easy access. + this.stackTraceRequestInfoByActorID.set(actor, { + targetFront, + stacktraceResourceId, + }); + } else if (cause) { + // If the stacktrace for this request is not available, and we + // expect that this request should have a stacktrace, lets store + // some useful info for when the NETWORK_EVENT_STACKTRACE resource + // finally comes. + this.stackTraces.set(stacktraceResourceId, { actor, cause }); + } + await this.addRequest(actor, resource); + this.emitForTests(TEST_EVENTS.NETWORK_EVENT, resource); + } + + /** + * The handler for when the network event resource is updated. + * + * @param {object} resource The updated network event resource. + */ + async onNetworkResourceUpdated(resource) { + // Identify the channel as SSE if mimeType is event-stream. + if (resource?.mimeType?.includes("text/event-stream")) { + await this.setEventStreamFlag(resource.actor); + } + + this.pendingRequests.delete(resource.actor); + if (this.actionsEnabled && this.actions.updateRequest) { + await this.actions.updateRequest(resource.actor, resource, true); + } + + // This event is fired only once per request, once all the properties are fetched + // from `onNetworkResourceUpdated`. There should be no more RDP requests after this. + // Note that this event might be consumed by extension so, emit it in production + // release as well. + this.emitForTests(TEST_EVENTS.NETWORK_EVENT_UPDATED, resource.actor); + this.emit(EVENTS.PAYLOAD_READY, resource); + } + + /** + * The "webSocketOpened" message type handler. + * + * @param {number} httpChannelId the channel ID + * @param {string} effectiveURI the effective URI of the page + * @param {string} protocols webSocket protocols + * @param {string} extensions + */ + async onWebSocketOpened(httpChannelId, effectiveURI, protocols, extensions) {} + + /** + * The "webSocketClosed" message type handler. + * + * @param {number} httpChannelId + * @param {boolean} wasClean + * @param {number} code + * @param {string} reason + */ + async onWebSocketClosed(httpChannelId, wasClean, code, reason) { + if (this.actionsEnabled && this.actions.closeConnection) { + await this.actions.closeConnection(httpChannelId, wasClean, code, reason); + } + } + + /** + * The "frameSent" message type handler. + * + * @param {number} httpChannelId the channel ID + * @param {object} data websocket frame information + */ + async onFrameSent(httpChannelId, data) { + this.addMessage(httpChannelId, data); + } + + /** + * The "frameReceived" message type handler. + * + * @param {number} httpChannelId the channel ID + * @param {object} data websocket frame information + */ + async onFrameReceived(httpChannelId, data) { + this.addMessage(httpChannelId, data); + } + + /** + * Add a new WebSocket frame to application state. + * + * @param {number} httpChannelId the channel ID + * @param {object} data websocket frame information + */ + async addMessage(httpChannelId, data) { + if (this.actionsEnabled && this.actions.addMessage) { + await this.actions.addMessage(httpChannelId, data, true); + } + // TODO: Emit an event for test here + } + + /** + * Public connector API to lazily request HTTP details from the backend. + * + * The method focus on: + * - calling the right actor method, + * - emitting an event to tell we start fetching some request data, + * - call data processing method. + * + * @param {string} actor actor id (used as request id) + * @param {string} method identifier of the data we want to fetch + * + * @return {Promise} return a promise resolved when data is received. + */ + requestData(actor, method) { + // if this is `false`, do not try to request data as requests on the backend + // might no longer exist (usually `false` after requests are cleared). + if (!this._requestDataEnabled) { + return Promise.resolve(); + } + // Key string used in `lazyRequestData`. We use this Map to prevent requesting + // the same data twice at the same time. + const key = `${actor}-${method}`; + let promise = this.lazyRequestData.get(key); + // If a request is pending, reuse it. + if (promise) { + return promise; + } + // Fetch the data + promise = this._requestData(actor, method).then(async payload => { + // Remove the request from the cache, any new call to requestData will fetch the + // data again. + this.lazyRequestData.delete(key); + + if (this.actionsEnabled && this.actions.updateRequest) { + await this.actions.updateRequest( + actor, + { + ...payload, + // Lockdown *Available property once we fetch data from back-end. + // Using this as a flag to prevent fetching arrived data again. + [`${method}Available`]: false, + }, + true + ); + } + + return payload; + }); + + this.lazyRequestData.set(key, promise); + + return promise; + } + + /** + * Internal helper used to request HTTP details from the backend. + * + * This is internal method that focus on: + * - calling the right actor method, + * - emitting an event to tell we start fetching some request data, + * - call data processing method. + * + * @param {string} actor actor id (used as request id) + * @param {string} method identifier of the data we want to fetch + * + * @return {Promise} return a promise resolved when data is received. + */ + async _requestData(actor, method) { + // Backup the lastRequestDataClearId before doing any async processing. + const lastRequestDataClearId = this._lastRequestDataClearId; + + // Calculate real name of the client getter. + const clientMethodName = `get${method + .charAt(0) + .toUpperCase()}${method.slice(1)}`; + // The name of the callback that processes request response + const callbackMethodName = `on${method + .charAt(0) + .toUpperCase()}${method.slice(1)}`; + // And the event to fire before updating this data + const updatingEventName = `UPDATING_${method + .replace(/([A-Z])/g, "_$1") + .toUpperCase()}`; + + // Emit event that tell we just start fetching some data + this.emitForTests(EVENTS[updatingEventName], actor); + + // Make sure we fetch the real actor data instead of cloned actor + // e.g. CustomRequestPanel will clone a request with additional '-clone' actor id + const actorID = actor.replace("-clone", ""); + + // 'getStackTrace' is the only one to be fetched via the NetworkContent actor in content process + // while all other attributes are fetched from the NetworkEvent actors, running in the parent process + let response; + if ( + clientMethodName == "getStackTrace" && + this.commands.resourceCommand.hasResourceCommandSupport( + this.commands.resourceCommand.TYPES.NETWORK_EVENT_STACKTRACE + ) + ) { + const requestInfo = this.stackTraceRequestInfoByActorID.get(actorID); + const { stacktrace } = await this._getStackTraceFromWatcher(requestInfo); + this.stackTraceRequestInfoByActorID.delete(actorID); + response = { from: actor, stacktrace }; + } else { + // We don't create fronts for NetworkEvent actors, + // so that we have to do the request manually via DevToolsClient.request() + try { + const packet = { + to: actorID, + type: clientMethodName, + }; + response = await this.commands.client.request(packet); + } catch (e) { + if (this._lastRequestDataClearId !== lastRequestDataClearId) { + // If lastRequestDataClearId was updated, FirefoxDataProvider:clear() + // was called and all network event actors have been destroyed. + // Swallow errors to avoid unhandled promise rejections in tests. + console.warn( + `Firefox Data Provider destroyed while requesting data: ${e.message}` + ); + // Return an empty response packet to avoid too many callback errors. + response = { from: actor }; + } else { + throw new Error( + `Error while calling method ${clientMethodName}: ${e.message}` + ); + } + } + } + + // Restore clone actor id + if (actor.includes("-clone")) { + // Because response's properties are read-only, we create a new response + response = { ...response, from: `${response.from}-clone` }; + } + + // Call data processing method. + return this[callbackMethodName](response); + } + + /** + * Handles additional information received for a "requestHeaders" packet. + * + * @param {object} response the message received from the server. + */ + async onRequestHeaders(response) { + const payload = await this.updateRequest(response.from, { + requestHeaders: response, + }); + this.emitForTests(TEST_EVENTS.RECEIVED_REQUEST_HEADERS, response); + return payload.requestHeaders; + } + + /** + * Handles additional information received for a "responseHeaders" packet. + * + * @param {object} response the message received from the server. + */ + async onResponseHeaders(response) { + const payload = await this.updateRequest(response.from, { + responseHeaders: response, + }); + this.emitForTests(TEST_EVENTS.RECEIVED_RESPONSE_HEADERS, response); + return payload.responseHeaders; + } + + /** + * Handles additional information received for a "requestCookies" packet. + * + * @param {object} response the message received from the server. + */ + async onRequestCookies(response) { + const payload = await this.updateRequest(response.from, { + requestCookies: response, + }); + this.emitForTests(TEST_EVENTS.RECEIVED_REQUEST_COOKIES, response); + return payload.requestCookies; + } + + /** + * Handles additional information received for a "requestPostData" packet. + * + * @param {object} response the message received from the server. + */ + async onRequestPostData(response) { + const payload = await this.updateRequest(response.from, { + requestPostData: response, + }); + this.emitForTests(TEST_EVENTS.RECEIVED_REQUEST_POST_DATA, response); + return payload.requestPostData; + } + + /** + * Handles additional information received for a "securityInfo" packet. + * + * @param {object} response the message received from the server. + */ + async onSecurityInfo(response) { + const payload = await this.updateRequest(response.from, { + securityInfo: response.securityInfo, + }); + this.emitForTests(TEST_EVENTS.RECEIVED_SECURITY_INFO, response); + return payload.securityInfo; + } + + /** + * Handles additional information received for a "responseCookies" packet. + * + * @param {object} response the message received from the server. + */ + async onResponseCookies(response) { + const payload = await this.updateRequest(response.from, { + responseCookies: response, + }); + this.emitForTests(TEST_EVENTS.RECEIVED_RESPONSE_COOKIES, response); + return payload.responseCookies; + } + + /** + * Handles additional information received for a "responseCache" packet. + * @param {object} response the message received from the server. + */ + async onResponseCache(response) { + const payload = await this.updateRequest(response.from, { + responseCache: response, + }); + this.emitForTests(TEST_EVENTS.RECEIVED_RESPONSE_CACHE, response); + return payload.responseCache; + } + + /** + * Handles additional information received via "getResponseContent" request. + * + * @param {object} response the message received from the server. + */ + async onResponseContent(response) { + const payload = await this.updateRequest(response.from, { + // We have to ensure passing mimeType as fetchResponseContent needs it from + // updateRequest. It will convert the LongString in `response.content.text` to a + // string. + mimeType: response.content.mimeType, + responseContent: response, + }); + this.emitForTests(TEST_EVENTS.RECEIVED_RESPONSE_CONTENT, response); + return payload.responseContent; + } + + /** + * Handles additional information received for a "eventTimings" packet. + * + * @param {object} response the message received from the server. + */ + async onEventTimings(response) { + const payload = await this.updateRequest(response.from, { + eventTimings: response, + }); + + // This event is utilized only in tests but, DAMP is using it too + // and running DAMP test doesn't set the `devtools.testing` flag. + // So, emit this event even in the production mode. + // See also: https://bugzilla.mozilla.org/show_bug.cgi?id=1578215 + this.emit(EVENTS.RECEIVED_EVENT_TIMINGS, response); + return payload.eventTimings; + } + + /** + * Handles information received for a "stackTrace" packet. + * + * @param {object} response the message received from the server. + */ + async onStackTrace(response) { + const payload = await this.updateRequest(response.from, { + stacktrace: response.stacktrace, + }); + this.emitForTests(TEST_EVENTS.RECEIVED_EVENT_STACKTRACE, response); + return payload.stacktrace; + } + + /** + * Handle EventSource events. + */ + + async onEventSourceConnectionClosed(httpChannelId) { + if (this.actionsEnabled && this.actions.closeConnection) { + await this.actions.closeConnection(httpChannelId); + } + } + + async onEventReceived(httpChannelId, data) { + // Dispatch the same action used by websocket inspector. + this.addMessage(httpChannelId, data); + } + + async setEventStreamFlag(actorId) { + if (this.actionsEnabled && this.actions.setEventStreamFlag) { + await this.actions.setEventStreamFlag(actorId, true); + } + } + + /** + * Fire events for the owner object. + */ + emit(type, data) { + if (this.owner) { + this.owner.emit(type, data); + } + } + + /** + * Fire test events for the owner object. These events are + * emitted only when tests are running. + */ + emitForTests(type, data) { + if (this.owner) { + this.owner.emitForTests(type, data); + } + } +} + +module.exports = FirefoxDataProvider; |