diff options
Diffstat (limited to 'devtools/client/netmonitor/src/utils')
19 files changed, 2612 insertions, 0 deletions
diff --git a/devtools/client/netmonitor/src/utils/context-menu-utils.js b/devtools/client/netmonitor/src/utils/context-menu-utils.js new file mode 100644 index 0000000000..3b44ff20cc --- /dev/null +++ b/devtools/client/netmonitor/src/utils/context-menu-utils.js @@ -0,0 +1,32 @@ +/* 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"; + +/** + * The default format for the content copied to the + * clipboard when the `Copy Value` option is selected. + */ +function baseCopyFormatter({ name, value, object, hasChildren }) { + if (hasChildren) { + return baseCopyAllFormatter({ [name]: value }); + } + return `${value}`; +} + +/** + * The default format for the content copied to the + * clipboard when the `Copy All` option is selected. + * @param {Object} object The whole data object + */ +function baseCopyAllFormatter(object) { + return JSON.stringify(object, null, "\t"); +} + +module.exports = { + contextMenuFormatters: { + baseCopyFormatter, + baseCopyAllFormatter, + }, +}; diff --git a/devtools/client/netmonitor/src/utils/doc-utils.js b/devtools/client/netmonitor/src/utils/doc-utils.js new file mode 100644 index 0000000000..c08d96c453 --- /dev/null +++ b/devtools/client/netmonitor/src/utils/doc-utils.js @@ -0,0 +1,224 @@ +/* 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 { + SUPPORTED_HTTP_CODES, +} = require("resource://devtools/client/netmonitor/src/constants.js"); + +/** + * A mapping of header names to external documentation. Any header included + * here will show a MDN link alongside it. + */ +const SUPPORTED_HEADERS = [ + "Accept", + "Accept-Charset", + "Accept-Encoding", + "Accept-Language", + "Accept-Ranges", + "Access-Control-Allow-Credentials", + "Access-Control-Allow-Headers", + "Access-Control-Allow-Methods", + "Access-Control-Allow-Origin", + "Access-Control-Expose-Headers", + "Access-Control-Max-Age", + "Access-Control-Request-Headers", + "Access-Control-Request-Method", + "Age", + "Allow", + "Authorization", + "Cache-Control", + "Clear-Site-Data", + "Connection", + "Content-Disposition", + "Content-Encoding", + "Content-Language", + "Content-Length", + "Content-Location", + "Content-Range", + "Content-Security-Policy", + "Content-Security-Policy-Report-Only", + "Content-Type", + "Cookie", + "Cookie2", + "DNT", + "Date", + "ETag", + "Early-Data", + "Expect", + "Expect-CT", + "Expires", + "Feature-Policy", + "Forwarded", + "From", + "Host", + "If-Match", + "If-Modified-Since", + "If-None-Match", + "If-Range", + "If-Unmodified-Since", + "Keep-Alive", + "Last-Modified", + "Location", + "Origin", + "Pragma", + "Proxy-Authenticate", + "Proxy-Authorization", + "Public-Key-Pins", + "Public-Key-Pins-Report-Only", + "Range", + "Referer", + "Referrer-Policy", + "Retry-After", + "Save-Data", + "Sec-Fetch-Dest", + "Sec-Fetch-Mode", + "Sec-Fetch-Site", + "Sec-Fetch-User", + "Sec-GPC", + "Server", + "Server-Timing", + "Set-Cookie", + "Set-Cookie2", + "SourceMap", + "Strict-Transport-Security", + "TE", + "Timing-Allow-Origin", + "Tk", + "Trailer", + "Transfer-Encoding", + "Upgrade-Insecure-Requests", + "User-Agent", + "Vary", + "Via", + "WWW-Authenticate", + "Warning", + "X-Content-Type-Options", + "X-DNS-Prefetch-Control", + "X-Forwarded-For", + "X-Forwarded-Host", + "X-Forwarded-Proto", + "X-Frame-Options", + "X-XSS-Protection", +]; + +const MDN_URL = "https://developer.mozilla.org/docs/"; +const MDN_STATUS_CODES_LIST_URL = `${MDN_URL}Web/HTTP/Status`; +const getGAParams = (panelId = "netmonitor") => { + return `?utm_source=mozilla&utm_medium=devtools-${panelId}&utm_campaign=default`; +}; + +// Base URL to DevTools user docs +const USER_DOC_URL = "https://firefox-source-docs.mozilla.org/devtools-user/"; + +/** + * Get the MDN URL for the specified header. + * + * @param {string} header Name of the header for the baseURL to use. + * + * @return {string} The MDN URL for the header, or null if not available. + */ +function getHeadersURL(header) { + const lowerCaseHeader = header.toLowerCase(); + const idx = SUPPORTED_HEADERS.findIndex( + item => item.toLowerCase() === lowerCaseHeader + ); + return idx > -1 + ? `${MDN_URL}Web/HTTP/Headers/${SUPPORTED_HEADERS[idx] + getGAParams()}` + : null; +} + +/** + * Get the MDN URL for the specified HTTP status code. + * + * @param {string} HTTP status code for the baseURL to use. + * + * @return {string} The MDN URL for the HTTP status code, or null if not available. + */ +function getHTTPStatusCodeURL(statusCode, panelId) { + return ( + (SUPPORTED_HTTP_CODES.includes(statusCode) + ? `${MDN_URL}Web/HTTP/Status/${statusCode}` + : MDN_STATUS_CODES_LIST_URL) + getGAParams(panelId) + ); +} + +/** + * Get the URL of the Timings tag for Network Monitor. + * + * @return {string} the URL of the Timings tag for Network Monitor. + */ +function getNetMonitorTimingsURL() { + return `${USER_DOC_URL}network_monitor/request_details/#network-monitor-request-details-timings-tab`; +} + +/** + * Get the URL for Performance Analysis + * + * @return {string} The URL for the documentation of Performance Analysis. + */ +function getPerformanceAnalysisURL() { + return `${USER_DOC_URL}network_monitor/performance_analysis/`; +} + +/** + * Get the URL for Filter box + * + * @return {string} The URL for the documentation of Filter box. + */ +function getFilterBoxURL() { + return `${USER_DOC_URL}network_monitor/request_list/#filtering-by-properties`; +} + +/** + * Get the MDN URL for Tracking Protection + * + * @return {string} The MDN URL for the documentation of Tracking Protection. + */ +function getTrackingProtectionURL() { + return `${MDN_URL}Mozilla/Firefox/Privacy/Tracking_Protection${getGAParams()}`; +} + +/** + * Get the MDN URL for CORS error reason, falls back to generic cors error page + * if reason is not understood. + * + * @param {int} reason: Blocked Reason message from `netmonitor/src/constants.js` + * + * @returns {string} the MDN URL for the documentation of CORS errors + */ +function getCORSErrorURL(reason) { + // Map from blocked reasons from netmonitor/src/constants.js to the correct + // URL fragment to append to MDN_URL + const reasonMap = new Map([ + [1001, "CORSDisabled"], + [1002, "CORSDidNotSucceed"], + [1003, "CORSRequestNotHttp"], + [1004, "CORSMultipleAllowOriginNotAllowed"], + [1005, "CORSMissingAllowOrigin"], + [1006, "CORSNotSupportingCredentials"], + [1007, "CORSAllowOriginNotMatchingOrigin"], + [1008, "CORSMIssingAllowCredentials"], + [1009, "CORSOriginHeaderNotAdded"], + [1010, "CORSExternalRedirectNotAllowed"], + [1011, "CORSPreflightDidNotSucceed"], + [1012, "CORSInvalidAllowMethod"], + [1013, "CORSMethodNotFound"], + [1014, "CORSInvalidAllowHeader"], + [1015, "CORSMissingAllowHeaderFromPreflight"], + ]); + const urlFrag = reasonMap.get(reason) || ""; + return `${MDN_URL}Web/HTTP/CORS/Errors/${urlFrag}`; +} + +module.exports = { + getHeadersURL, + getHTTPStatusCodeURL, + getNetMonitorTimingsURL, + getPerformanceAnalysisURL, + getFilterBoxURL, + getTrackingProtectionURL, + getCORSErrorURL, +}; diff --git a/devtools/client/netmonitor/src/utils/filter-autocomplete-provider.js b/devtools/client/netmonitor/src/utils/filter-autocomplete-provider.js new file mode 100644 index 0000000000..991027c67b --- /dev/null +++ b/devtools/client/netmonitor/src/utils/filter-autocomplete-provider.js @@ -0,0 +1,209 @@ +/* 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 { + FILTER_FLAGS, + SUPPORTED_HTTP_CODES, +} = require("resource://devtools/client/netmonitor/src/constants.js"); + +const { + getRequestPriorityAsText, +} = require("resource://devtools/client/netmonitor/src/utils/format-utils.js"); + +/** + * Generates a value for the given filter + * ie. if flag = status-code, will generate "200" from the given request item. + * For flags related to cookies, it might generate an array based on the request + * ie. ["cookie-name-1", "cookie-name-2", ...] + * + * @param {string} flag - flag specified in filter, ie. "status-code" + * @param {object} request - Network request item + * @return {string|Array} - The output is a string or an array based on the request + */ +function getAutocompleteValuesForFlag(flag, request) { + let values = []; + let { responseCookies = { cookies: [] } } = request; + responseCookies = responseCookies.cookies || responseCookies; + + switch (flag) { + case "status-code": + // Sometimes status comes as Number + values.push(String(request.status)); + break; + case "scheme": + values.push(request.urlDetails.scheme); + break; + case "domain": + values.push(request.urlDetails.host); + break; + case "remote-ip": + values.push(request.remoteAddress); + break; + case "cause": + values.push(request.cause.type); + break; + case "mime-type": + values.push((request.mimeType || "").replace(/;.+/, "")); + break; + case "set-cookie-name": + values = responseCookies.map(c => c.name); + break; + case "set-cookie-value": + values = responseCookies.map(c => c.value); + break; + case "priority": + values.push(getRequestPriorityAsText(request.priority)); + break; + case "set-cookie-domain": + values = responseCookies.map(c => + c.hasOwnProperty("domain") ? c.domain : request.urlDetails.host + ); + break; + case "is": + values = ["cached", "from-cache", "running"]; + break; + case "has-response-header": + // Some requests not having responseHeaders..? + values = request.responseHeaders + ? request.responseHeaders.headers.map(h => h.name) + : []; + break; + case "protocol": + values.push(request.httpVersion); + break; + case "method": + default: + values.push(request[flag]); + } + + return values; +} + +/** + * For a given lastToken passed ie. "is:", returns an array of populated flag + * values for consumption in autocompleteProvider + * ie. ["is:cached", "is:running", "is:from-cache"] + * + * @param {string} lastToken - lastToken parsed from filter input, ie "is:" + * @param {object} requests - List of requests from which values are generated + * @return {Array} - array of autocomplete values + */ +function getLastTokenFlagValues(lastToken, requests) { + // The last token must be a string like "method:GET" or "method:", Any token + // without a ":" cant be used to parse out flag values + if (!lastToken.includes(":")) { + return []; + } + + // Parse out possible flag from lastToken + let [flag, typedFlagValue] = lastToken.split(":"); + let isNegativeFlag = false; + + // Check if flag is used with negative match + if (flag.startsWith("-")) { + flag = flag.slice(1); + isNegativeFlag = true; + } + + // Flag is some random string, return + if (!FILTER_FLAGS.includes(flag)) { + return []; + } + + let values = []; + for (const request of requests) { + values.push(...getAutocompleteValuesForFlag(flag, request)); + } + values = [...new Set(values)]; + + return values + .filter(value => value) + .filter(value => { + if (typedFlagValue && value) { + const lowerTyped = typedFlagValue.toLowerCase(); + const lowerValue = value.toLowerCase(); + return lowerValue.includes(lowerTyped) && lowerValue !== lowerTyped; + } + return ( + typeof value !== "undefined" && value !== "" && value !== "undefined" + ); + }) + .sort() + .map(value => (isNegativeFlag ? `-${flag}:${value}` : `${flag}:${value}`)); +} + +/** + * Generates an autocomplete list for the search-box for network monitor + * + * It expects an entire string of the searchbox ie "is:cached pr". + * The string is then tokenized into "is:cached" and "pr" + * + * @param {string} filter - The entire search string of the search box + * @param {object} requests - Iteratable object of requests displayed + * @return {Array} - The output is an array of objects as below + * [{value: "is:cached protocol", displayValue: "protocol"}[, ...]] + * `value` is used to update the search-box input box for given item + * `displayValue` is used to render the autocomplete list + */ +function autocompleteProvider(filter, requests) { + if (!filter) { + return []; + } + + const negativeAutocompleteList = FILTER_FLAGS.map(item => `-${item}`); + const baseList = [...FILTER_FLAGS, ...negativeAutocompleteList].map( + item => `${item}:` + ); + + // The last token is used to filter the base autocomplete list + const tokens = filter.split(/\s+/g); + const lastToken = tokens[tokens.length - 1]; + const previousTokens = tokens.slice(0, tokens.length - 1); + + // Autocomplete list is not generated for empty lastToken + if (!lastToken) { + return []; + } + + let autocompleteList; + const availableValues = getLastTokenFlagValues(lastToken, requests); + if (availableValues.length) { + autocompleteList = availableValues; + } else { + const isNegativeFlag = lastToken.startsWith("-"); + + // Stores list of HTTP codes that starts with value of lastToken + const filteredStatusCodes = SUPPORTED_HTTP_CODES.filter(item => { + item = isNegativeFlag ? item.substr(1) : item; + return item.toLowerCase().startsWith(lastToken.toLowerCase()); + }); + + if (filteredStatusCodes.length) { + // Shows an autocomplete list of "status-code" values from filteredStatusCodes + autocompleteList = isNegativeFlag + ? filteredStatusCodes.map(item => `-status-code:${item}`) + : filteredStatusCodes.map(item => `status-code:${item}`); + } else { + // Shows an autocomplete list of values from baseList + // that starts with value of lastToken + autocompleteList = baseList.filter(item => { + return ( + item.toLowerCase().startsWith(lastToken.toLowerCase()) && + item.toLowerCase() !== lastToken.toLowerCase() + ); + }); + } + } + + return autocompleteList.sort().map(item => ({ + value: [...previousTokens, item].join(" "), + displayValue: item, + })); +} + +module.exports = { + autocompleteProvider, +}; diff --git a/devtools/client/netmonitor/src/utils/filter-predicates.js b/devtools/client/netmonitor/src/utils/filter-predicates.js new file mode 100644 index 0000000000..63105dec0c --- /dev/null +++ b/devtools/client/netmonitor/src/utils/filter-predicates.js @@ -0,0 +1,137 @@ +/* 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 { + isFreetextMatch, +} = require("resource://devtools/client/netmonitor/src/utils/filter-text-utils.js"); + +/** + * Predicates used when filtering items. + * + * @param object item + * The filtered item. + * @return boolean + * True if the item should be visible, false otherwise. + */ +function all() { + return true; +} + +function isHtml({ mimeType }) { + return ( + mimeType && (mimeType.includes("/html") || mimeType.includes("/xhtml+xml")) + ); +} + +function isCss({ mimeType }) { + return mimeType && mimeType.includes("/css"); +} + +function isJs({ mimeType }) { + return ( + mimeType && + (mimeType.includes("/ecmascript") || + mimeType.includes("/javascript") || + mimeType.includes("/x-javascript")) + ); +} + +function isXHR(item) { + // Show the request it is XHR, except if the request is a WS upgrade + return item.isXHR && !isWS(item); +} + +function isFont({ url, mimeType }) { + // Fonts are a mess. + return ( + (mimeType && (mimeType.includes("font/") || mimeType.includes("/font"))) || + url.includes(".eot") || + url.includes(".ttf") || + url.includes(".otf") || + url.includes(".woff") + ); +} + +function isImage({ mimeType, cause }) { + // We check cause.type so anything loaded via "img", "imageset", "lazy-img", etc. is in the right category + // When mimeType is not set to "image/", we still "detect" the image with cause.type (Bug-1654257) + return ( + mimeType?.includes("image/") || + cause?.type.includes("img") || + cause?.type.includes("image") + ); +} + +function isMedia({ mimeType }) { + // Not including images. + return ( + mimeType && + (mimeType.includes("audio/") || + mimeType.includes("video/") || + mimeType.includes("model/") || + mimeType === "application/vnd.apple.mpegurl" || + mimeType === "application/x-mpegurl" || + mimeType === "application/ogg") + ); +} + +function isWS({ requestHeaders, responseHeaders, cause }) { + // For the first call, the requestHeaders is not ready(empty), + // so checking for cause.type instead (Bug-1454962) + if (typeof cause.type === "string" && cause.type === "websocket") { + return true; + } + // Detect a websocket upgrade if request has an Upgrade header with value 'websocket' + if (!requestHeaders || !Array.isArray(requestHeaders.headers)) { + return false; + } + + // Find the 'upgrade' header. + let upgradeHeader = requestHeaders.headers.find(header => { + return header.name.toLowerCase() == "upgrade"; + }); + + // If no header found on request, check response - mainly to get + // something we can unit test, as it is impossible to set + // the Upgrade header on outgoing XHR as per the spec. + if ( + !upgradeHeader && + responseHeaders && + Array.isArray(responseHeaders.headers) + ) { + upgradeHeader = responseHeaders.headers.find(header => { + return header.name.toLowerCase() == "upgrade"; + }); + } + + // Return false if there is no such header or if its value isn't 'websocket'. + if (!upgradeHeader || upgradeHeader.value != "websocket") { + return false; + } + + return true; +} + +function isOther(item) { + const tests = [isHtml, isCss, isJs, isXHR, isFont, isImage, isMedia, isWS]; + return tests.every(is => !is(item)); +} + +module.exports = { + Filters: { + all, + html: isHtml, + css: isCss, + js: isJs, + xhr: isXHR, + fonts: isFont, + images: isImage, + media: isMedia, + ws: isWS, + other: isOther, + }, + isFreetextMatch, +}; diff --git a/devtools/client/netmonitor/src/utils/filter-text-utils.js b/devtools/client/netmonitor/src/utils/filter-text-utils.js new file mode 100644 index 0000000000..911e70e4bd --- /dev/null +++ b/devtools/client/netmonitor/src/utils/filter-text-utils.js @@ -0,0 +1,291 @@ +/* + * Copyright (c) 2013 Google Inc. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Google Inc. nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +"use strict"; + +const { + FILTER_FLAGS, + SUPPORTED_HTTP_CODES, +} = require("resource://devtools/client/netmonitor/src/constants.js"); +const { + getFormattedIPAndPort, + getRequestPriorityAsText, +} = require("resource://devtools/client/netmonitor/src/utils/format-utils.js"); +const { + getUnicodeUrl, +} = require("resource://devtools/client/shared/unicode-url.js"); +const { + getUrlBaseName, +} = require("resource://devtools/client/netmonitor/src/utils/request-utils.js"); + +/* + The function `parseFilters` is from: + https://github.com/ChromeDevTools/devtools-frontend/ + + front_end/network/FilterSuggestionBuilder.js#L138-L163 + Commit f340aefd7ec9b702de9366a812288cfb12111fce +*/ + +function parseFilters(query) { + const flags = []; + const text = []; + const parts = query.split(/\s+/); + + for (const part of parts) { + if (!part) { + continue; + } + const colonIndex = part.indexOf(":"); + if (colonIndex === -1) { + const isNegative = part.startsWith("-"); + // Stores list of HTTP codes that starts with value of lastToken + const filteredStatusCodes = SUPPORTED_HTTP_CODES.filter(item => { + item = isNegative ? item.substr(1) : item; + return item.toLowerCase().startsWith(part.toLowerCase()); + }); + + if (filteredStatusCodes.length) { + flags.push({ + type: "status-code", // a standard key before a colon + value: isNegative ? part.substring(1) : part, + isNegative, + }); + continue; + } + + // Value of lastToken is just text that does not correspond to status codes + text.push(part); + continue; + } + let key = part.substring(0, colonIndex); + const negative = key.startsWith("-"); + if (negative) { + key = key.substring(1); + } + if (!FILTER_FLAGS.includes(key)) { + text.push(part); + continue; + } + let value = part.substring(colonIndex + 1); + value = processFlagFilter(key, value); + flags.push({ + type: key, + value, + negative, + }); + } + + return { text, flags }; +} + +function processFlagFilter(type, value) { + switch (type) { + case "regexp": + return value; + case "size": + case "transferred": + case "larger-than": + case "transferred-larger-than": + let multiplier = 1; + if (value.endsWith("k")) { + multiplier = 1000; + value = value.substring(0, value.length - 1); + } else if (value.endsWith("m")) { + multiplier = 1000 * 1000; + value = value.substring(0, value.length - 1); + } + const quantity = Number(value); + if (isNaN(quantity)) { + return null; + } + return quantity * multiplier; + default: + return value.toLowerCase(); + } +} + +function isFlagFilterMatch(item, { type, value, negative }) { + if (value == null) { + return false; + } + + // Ensures when filter token is exactly a flag ie. "remote-ip:", all values are shown + if (value.length < 1) { + return true; + } + + let match = true; + let { responseCookies = { cookies: [] } } = item; + responseCookies = responseCookies.cookies || responseCookies; + + const matchers = { + "status-code": () => + item.status && item.status.toString().startsWith(value), + method: () => item.method.toLowerCase() === value, + protocol: () => { + const protocol = item.httpVersion; + return typeof protocol === "string" + ? protocol.toLowerCase().includes(value) + : false; + }, + domain: () => item.urlDetails.host.toLowerCase().includes(value), + "remote-ip": () => { + const data = getFormattedIPAndPort(item.remoteAddress, item.remotePort); + return data ? data.toLowerCase().includes(value) : false; + }, + "has-response-header": () => { + if (typeof item.responseHeaders === "object") { + const { headers } = item.responseHeaders; + return headers.findIndex(h => h.name.toLowerCase() === value) > -1; + } + return false; + }, + cause: () => { + const causeType = item.cause.type; + return typeof causeType === "string" + ? causeType.toLowerCase().includes(value) + : false; + }, + initiator: () => { + const initiator = item.cause.lastFrame + ? getUrlBaseName(item.cause.lastFrame.filename) + + ":" + + item.cause.lastFrame.lineNumber + : ""; + return typeof initiator === "string" + ? initiator.toLowerCase().includes(value) + : !value; + }, + transferred: () => { + if (item.fromCache) { + return false; + } + return isSizeMatch(value, item.transferredSize); + }, + size: () => isSizeMatch(value, item.contentSize), + "larger-than": () => item.contentSize > value, + "transferred-larger-than": () => { + if (item.fromCache) { + return false; + } + return item.transferredSize > value; + }, + "mime-type": () => { + if (!item.mimeType) { + return false; + } + return item.mimeType.includes(value); + }, + is: () => { + if (value === "from-cache" || value === "cached") { + return item.fromCache || item.status === "304"; + } + if (value === "running") { + return !item.status; + } + return match; + }, + scheme: () => item.urlDetails.scheme === value, + regexp: () => { + try { + const pattern = new RegExp(value); + return pattern.test(item.url); + } catch (e) { + return false; + } + }, + priority: () => + getRequestPriorityAsText(item.priority).toLowerCase() == value, + "set-cookie-domain": () => { + if (responseCookies.length) { + const { host } = item.urlDetails; + const i = responseCookies.findIndex(c => { + const domain = c.hasOwnProperty("domain") ? c.domain : host; + return domain.includes(value); + }); + return i > -1; + } + return false; + }, + "set-cookie-name": () => + responseCookies.findIndex(c => c.name.toLowerCase().includes(value)) > -1, + "set-cookie-value": () => + responseCookies.findIndex(c => c.value.toLowerCase().includes(value)) > + -1, + }; + + const matcher = matchers[type]; + if (matcher) { + match = matcher(); + } + + return negative ? !match : match; +} + +function isSizeMatch(value, size) { + return value >= size - size / 10 && value <= size + size / 10; +} + +function isTextFilterMatch({ url }, text) { + const lowerCaseUrl = getUnicodeUrl(url).toLowerCase(); + let lowerCaseText = text.toLowerCase(); + const textLength = text.length; + // Support negative filtering + if (text.startsWith("-") && textLength > 1) { + lowerCaseText = lowerCaseText.substring(1, textLength); + return !lowerCaseUrl.includes(lowerCaseText); + } + + // no text is a positive match + return !text || lowerCaseUrl.includes(lowerCaseText); +} + +function isFreetextMatch(item, text) { + if (!text) { + return true; + } + + const filters = parseFilters(text); + let match = true; + + for (const textFilter of filters.text) { + match = match && isTextFilterMatch(item, textFilter); + } + + for (const flagFilter of filters.flags) { + match = match && isFlagFilterMatch(item, flagFilter); + } + + return match; +} + +module.exports = { + isFreetextMatch, +}; diff --git a/devtools/client/netmonitor/src/utils/firefox/moz.build b/devtools/client/netmonitor/src/utils/firefox/moz.build new file mode 100644 index 0000000000..bb0acafdf7 --- /dev/null +++ b/devtools/client/netmonitor/src/utils/firefox/moz.build @@ -0,0 +1,8 @@ +# 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( + "open-request-in-tab.js", +) diff --git a/devtools/client/netmonitor/src/utils/firefox/open-request-in-tab.js b/devtools/client/netmonitor/src/utils/firefox/open-request-in-tab.js new file mode 100644 index 0000000000..20ea3dcba2 --- /dev/null +++ b/devtools/client/netmonitor/src/utils/firefox/open-request-in-tab.js @@ -0,0 +1,67 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// This file is a chrome-API-dependent version of the module +// devtools/client/netmonitor/src/utils/open-request-in-tab.js, so that it can +// take advantage of utilizing chrome APIs. But because of this, it isn't +// intended to be used in Chrome-API-free applications, such as the Launchpad. +// +// Please keep in mind that if the feature in this file has changed, don't +// forget to also change that accordingly in +// devtools/client/netmonitor/src/utils/open-request-in-tab.js. + +"use strict"; + +const { + gDevTools, +} = require("resource://devtools/client/framework/devtools.js"); + +/** + * Opens given request in a new tab. + */ +function openRequestInTab(url, requestHeaders, requestPostData) { + const win = Services.wm.getMostRecentWindow(gDevTools.chromeWindowType); + const rawData = requestPostData ? requestPostData.postData : null; + let postData; + + if (rawData?.text) { + const stringStream = getInputStreamFromString(rawData.text); + postData = Cc["@mozilla.org/network/mime-input-stream;1"].createInstance( + Ci.nsIMIMEInputStream + ); + + const contentTypeHeader = requestHeaders.headers.find(e => { + return e.name.toLowerCase() === "content-type"; + }); + + postData.addHeader( + "Content-Type", + contentTypeHeader + ? contentTypeHeader.value + : "application/x-www-form-urlencoded" + ); + postData.setData(stringStream); + } + const { userContextId } = win.gBrowser.contentPrincipal; + win.gBrowser.selectedTab = win.gBrowser.addWebTab(url, { + // TODO this should be using the original request principal + triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal({ + userContextId, + }), + userContextId, + postData, + }); +} + +function getInputStreamFromString(data) { + const stringStream = Cc[ + "@mozilla.org/io/string-input-stream;1" + ].createInstance(Ci.nsIStringInputStream); + stringStream.data = data; + return stringStream; +} + +module.exports = { + openRequestInTab, +}; diff --git a/devtools/client/netmonitor/src/utils/format-utils.js b/devtools/client/netmonitor/src/utils/format-utils.js new file mode 100644 index 0000000000..c0d5c29818 --- /dev/null +++ b/devtools/client/netmonitor/src/utils/format-utils.js @@ -0,0 +1,132 @@ +/* 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 { + L10N, +} = require("resource://devtools/client/netmonitor/src/utils/l10n.js"); + +// Constants for formatting bytes. +const BYTES_IN_KB = 1000; +const BYTES_IN_MB = Math.pow(BYTES_IN_KB, 2); +const BYTES_IN_GB = Math.pow(BYTES_IN_KB, 3); +const MAX_BYTES_SIZE = 1000; +const MAX_KB_SIZE = 1000 * BYTES_IN_KB; +const MAX_MB_SIZE = 1000 * BYTES_IN_MB; + +// Constants for formatting time. +const MAX_MILLISECOND = 1000; +const MAX_SECOND = 60 * MAX_MILLISECOND; + +const REQUEST_DECIMALS = 2; + +// Constants for formatting the priority, derived from nsISupportsPriority.idl +const PRIORITY_HIGH = -10; +const PRIORITY_NORMAL = 0; +const PRIORITY_LOW = 10; + +function getSizeWithDecimals(size, decimals = REQUEST_DECIMALS) { + return L10N.numberWithDecimals(size, decimals); +} + +function getTimeWithDecimals(time) { + return L10N.numberWithDecimals(time, REQUEST_DECIMALS); +} + +function formatDecimals(size, decimals) { + return size % 1 > 0 ? decimals : 0; +} + +/** + * Get a human-readable string from a number of bytes, with the B, kB, MB, or + * GB value. + */ +function getFormattedSize(bytes, decimals = REQUEST_DECIMALS) { + if (bytes < MAX_BYTES_SIZE) { + return L10N.getFormatStr("networkMenu.sizeB", bytes); + } + if (bytes < MAX_KB_SIZE) { + const kb = bytes / BYTES_IN_KB; + const formattedDecimals = formatDecimals(kb, decimals); + + return L10N.getFormatStr( + "networkMenu.size.kB", + getSizeWithDecimals(kb, formattedDecimals) + ); + } + if (bytes < MAX_MB_SIZE) { + const mb = bytes / BYTES_IN_MB; + const formattedDecimals = formatDecimals(mb, decimals); + return L10N.getFormatStr( + "networkMenu.sizeMB", + getSizeWithDecimals(mb, formattedDecimals) + ); + } + const gb = bytes / BYTES_IN_GB; + const formattedDecimals = formatDecimals(gb, decimals); + return L10N.getFormatStr( + "networkMenu.sizeGB", + getSizeWithDecimals(gb, formattedDecimals) + ); +} + +/** + * Get a human-readable string from a number of time, with the ms, s, or min + * value. + */ +function getFormattedTime(ms) { + if (ms < MAX_MILLISECOND) { + return L10N.getFormatStr("networkMenu.millisecond", ms | 0); + } + if (ms < MAX_SECOND) { + const sec = ms / MAX_MILLISECOND; + return L10N.getFormatStr("networkMenu.second", getTimeWithDecimals(sec)); + } + const min = ms / MAX_SECOND; + return L10N.getFormatStr("networkMenu.minute", getTimeWithDecimals(min)); +} + +/** + * Formats IP (v4 and v6) and port + * + * @param {string} ip - IP address + * @param {string} port + * @return {string} the formatted IP + port + */ +function getFormattedIPAndPort(ip, port) { + if (!port) { + return ip; + } + return ip.match(/:+/) ? `[${ip}]:${port}` : `${ip}:${port}`; +} + +/** + * Formats the priority of a request + * Based on unix conventions + * See xpcom/threads/nsISupportsPriority.idl + * + * @param {Number} priority - request priority + */ +function getRequestPriorityAsText(priority) { + if (priority < PRIORITY_HIGH) { + return "Highest"; + } else if (priority >= PRIORITY_HIGH && priority < PRIORITY_NORMAL) { + return "High"; + } else if (priority === PRIORITY_NORMAL) { + return "Normal"; + } else if (priority > PRIORITY_NORMAL && priority <= PRIORITY_LOW) { + return "Low"; + } + return "Lowest"; +} + +module.exports = { + getFormattedIPAndPort, + getFormattedSize, + getFormattedTime, + getSizeWithDecimals, + getTimeWithDecimals, + getRequestPriorityAsText, +}; diff --git a/devtools/client/netmonitor/src/utils/headers-provider.js b/devtools/client/netmonitor/src/utils/headers-provider.js new file mode 100644 index 0000000000..a76e7d1c0e --- /dev/null +++ b/devtools/client/netmonitor/src/utils/headers-provider.js @@ -0,0 +1,90 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + ObjectProvider, +} = require("resource://devtools/client/shared/components/tree/ObjectProvider.js"); + +/** + * Custom tree provider. + * + * This provider is used to provide set of headers and is + * utilized by the HeadersPanel. + * The default ObjectProvider can't be used since it doesn't + * allow duplicities by design and so it can't support duplicity + * headers (more headers with the same name). + */ +var HeadersProvider = { + ...ObjectProvider, + + getChildren(object) { + if (object && object.value instanceof HeaderList) { + return object.value.headers.map( + (header, index) => new Header(header.name, header.value, index) + ); + } + return ObjectProvider.getChildren(object); + }, + + hasChildren(object) { + if (object.value instanceof HeaderList) { + return !!object.value.headers.length; + } else if (object instanceof Header) { + return false; + } + return ObjectProvider.hasChildren(object); + }, + + getLabel(object) { + if (object instanceof Header) { + return object.name; + } + return ObjectProvider.getLabel(object); + }, + + getValue(object) { + if (object instanceof Header) { + return object.value; + } + return ObjectProvider.getValue(object); + }, + + getKey(object) { + if (object instanceof Header) { + return object.key; + } + return ObjectProvider.getKey(object); + }, + + getType(object) { + if (object instanceof Header) { + return "string"; + } + return ObjectProvider.getType(object); + }, +}; + +/** + * Helper data structures for list of headers. + */ +function HeaderList(headers) { + // Clone, so the sort doesn't affect the original array. + this.headers = headers.slice(0); + this.headers.sort((a, b) => { + return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); + }); +} + +function Header(name, value, key) { + this.name = name; + this.value = value; + this.key = key; +} + +module.exports = { + HeadersProvider, + HeaderList, +}; diff --git a/devtools/client/netmonitor/src/utils/l10n.js b/devtools/client/netmonitor/src/utils/l10n.js new file mode 100644 index 0000000000..74c8b22299 --- /dev/null +++ b/devtools/client/netmonitor/src/utils/l10n.js @@ -0,0 +1,11 @@ +/* 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 { LocalizationHelper } = require("resource://devtools/shared/l10n.js"); + +const NET_STRINGS_URI = "devtools/client/locales/netmonitor.properties"; + +exports.L10N = new LocalizationHelper(NET_STRINGS_URI); diff --git a/devtools/client/netmonitor/src/utils/moz.build b/devtools/client/netmonitor/src/utils/moz.build new file mode 100644 index 0000000000..2eb6aec69c --- /dev/null +++ b/devtools/client/netmonitor/src/utils/moz.build @@ -0,0 +1,27 @@ +# 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 += [ + "firefox", +] + +DevToolsModules( + "context-menu-utils.js", + "doc-utils.js", + "filter-autocomplete-provider.js", + "filter-predicates.js", + "filter-text-utils.js", + "format-utils.js", + "headers-provider.js", + "l10n.js", + "open-request-in-tab.js", + "powershell.js", + "prefs.js", + "request-blocking.js", + "request-utils.js", + "sort-predicates.js", + "sort-utils.js", + "tooltips.js", +) diff --git a/devtools/client/netmonitor/src/utils/open-request-in-tab.js b/devtools/client/netmonitor/src/utils/open-request-in-tab.js new file mode 100644 index 0000000000..cb63da61ec --- /dev/null +++ b/devtools/client/netmonitor/src/utils/open-request-in-tab.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/. */ + +// This file is a chrome-API-free version of the module +// devtools/client/netmonitor/src/utils/firefox/open-request-in-tab.js, so that +// it can be used in Chrome-API-free applications, such as the Launchpad. But +// because of this, it cannot take advantage of utilizing chrome APIs and should +// implement the similar functionalities on its own. +// +// Please keep in mind that if the feature in this file has changed, don't +// forget to also change that accordingly in +// devtools/client/netmonitor/src/utils/firefox/open-request-in-tab.js. + +"use strict"; + +loader.lazyRequireGetter( + this, + "openContentLink", + "resource://devtools/client/shared/link.js", + true +); + +/** + * Opens given request in a new tab. + * + * For POST request supports application/x-www-form-urlencoded content-type only. + */ +function openRequestInTab(url, requestHeaders, requestPostData) { + if (!requestPostData) { + openContentLink(url, { relatedToCurrent: true }); + } else { + openPostRequestInTabHelper({ + url, + data: requestPostData.postData, + }); + } +} + +function openPostRequestInTabHelper({ url, data }) { + const form = document.createElement("form"); + form.target = "_blank"; + form.action = url; + form.method = "post"; + + if (data) { + for (const key in data) { + const input = document.createElement("input"); + input.name = key; + input.value = data[key]; + form.appendChild(input); + } + } + + form.hidden = true; + document.body.appendChild(form); + form.submit(); + form.remove(); +} + +module.exports = { + openRequestInTab, +}; diff --git a/devtools/client/netmonitor/src/utils/powershell.js b/devtools/client/netmonitor/src/utils/powershell.js new file mode 100644 index 0000000000..2efcfd8faa --- /dev/null +++ b/devtools/client/netmonitor/src/utils/powershell.js @@ -0,0 +1,142 @@ +/* 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/. */ + +/* + * Copyright (C) 2007, 2008 Apple Inc. All rights reserved. + * Copyright (C) 2008, 2009 Anthony Ricaud <rik@webkit.org> + * Copyright (C) 2011 Google Inc. All rights reserved. + * Copyright (C) 2022 Mozilla Foundation. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * 3. Neither the name of Apple Computer, Inc. ("Apple") nor the names of + * its contributors may be used to endorse or promote products derived + * from this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL APPLE OR ITS CONTRIBUTORS BE LIABLE FOR ANY + * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF + * THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +// Utility to generate commands to invoke a request for powershell +"use strict"; + +// Some of these headers are passed in as seperate `Invoke-WebRequest` parameters so ignore +// when building the headers list, others are not to neccesarily restrict the request. +const IGNORED_HEADERS = [ + "connection", + "proxy-connection", + "content-length", + "expect", + "range", + "host", + "content-type", + "user-agent", + "cookie", +]; +/** + * This escapes strings for the powershell command + * + * 1. Escape the backtick, dollar sign and the double quotes See https://www.rlmueller.net/PowerShellEscape.htm + * 2. Convert any non printing ASCII characters found, using the ASCII code. + */ +function escapeStr(str) { + return `"${str + .replace(/[`\$"]/g, "`$&") + .replace(/[^\x20-\x7E]/g, char => "$([char]" + char.charCodeAt(0) + ")")}"`; +} + +const PowerShell = { + generateCommand(url, method, headers, postData, cookies) { + const parameters = []; + + // Create a WebSession to pass the information about cookies + // See https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.utility/invoke-webrequest?view=powershell-7.2#-websession + const session = []; + for (const { name, value, domain } of cookies) { + if (!session.length) { + session.push( + "$session = New-Object Microsoft.PowerShell.Commands.WebRequestSession" + ); + } + session.push( + `$session.Cookies.Add((New-Object System.Net.Cookie(${escapeStr( + name + )}, ${escapeStr(value)}, "/", ${escapeStr( + domain || new URL(url).host + )})))` + ); + } + + parameters.push(`-Uri ${escapeStr(url)}`); + + if (method !== "GET") { + parameters.push(`-Method ${method}`); + } + + if (session.length) { + parameters.push("-WebSession $session"); + } + + const userAgent = headers.find( + ({ name }) => name.toLowerCase() === "user-agent" + ); + if (userAgent) { + parameters.push("-UserAgent " + escapeStr(userAgent.value)); + } + + const headersStr = []; + for (let { name, value } of headers) { + // Translate any HTTP2 pseudo headers to HTTP headers + name = name.replace(/^:/, ""); + + if (IGNORED_HEADERS.includes(name.toLowerCase())) { + continue; + } + headersStr.push(`${escapeStr(name)} = ${escapeStr(value)}`); + } + if (headersStr.length) { + parameters.push(`-Headers @{\n${headersStr.join("\n ")}\n}`); + } + + const contentType = headers.find( + header => header.name.toLowerCase() === "content-type" + ); + if (contentType) { + parameters.push("-ContentType " + escapeStr(contentType.value)); + } + + const formData = postData.text; + if (formData) { + // Encode bytes if any of the characters is not an ASCII printing character (not between Space character and ~ character) + // a-zA-Z0-9 etc. See http://www.asciitable.com/ + const body = /[^\x20-\x7E]/.test(formData) + ? "([System.Text.Encoding]::UTF8.GetBytes(" + escapeStr(formData) + "))" + : escapeStr(formData); + parameters.push("-Body " + body); + } + + return `${ + session.length ? session.join("\n").concat("\n") : "" + // -UseBasicParsing is added for backward compatibility. + // See https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.utility/invoke-webrequest?view=powershell-7.2#-usebasicparsing + }Invoke-WebRequest -UseBasicParsing ${parameters.join(" `\n")}`; + }, +}; + +exports.PowerShell = PowerShell; diff --git a/devtools/client/netmonitor/src/utils/prefs.js b/devtools/client/netmonitor/src/utils/prefs.js new file mode 100644 index 0000000000..3c988f4294 --- /dev/null +++ b/devtools/client/netmonitor/src/utils/prefs.js @@ -0,0 +1,18 @@ +/* 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 { PrefsHelper } = require("resource://devtools/client/shared/prefs.js"); + +/** + * Shortcuts for accessing various network monitor preferences. + */ +exports.Prefs = new PrefsHelper("devtools.netmonitor", { + networkDetailsWidth: ["Int", "panes-network-details-width"], + networkDetailsHeight: ["Int", "panes-network-details-height"], + visibleColumns: ["Json", "visibleColumns"], + columnsData: ["Json", "columnsData"], + filters: ["Json", "filters"], +}); diff --git a/devtools/client/netmonitor/src/utils/request-blocking.js b/devtools/client/netmonitor/src/utils/request-blocking.js new file mode 100644 index 0000000000..67b217afe0 --- /dev/null +++ b/devtools/client/netmonitor/src/utils/request-blocking.js @@ -0,0 +1,13 @@ +/* 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"; + +function hasMatchingBlockingRequestPattern(blockedUrls, url) { + return blockedUrls.some(blockedUrl => url.includes(blockedUrl)); +} + +module.exports = { + hasMatchingBlockingRequestPattern, +}; diff --git a/devtools/client/netmonitor/src/utils/request-utils.js b/devtools/client/netmonitor/src/utils/request-utils.js new file mode 100644 index 0000000000..11273016be --- /dev/null +++ b/devtools/client/netmonitor/src/utils/request-utils.js @@ -0,0 +1,769 @@ +/* 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 { + getUnicodeUrl, + getUnicodeUrlPath, + getUnicodeHostname, +} = require("resource://devtools/client/shared/unicode-url.js"); + +const { + UPDATE_PROPS, +} = require("resource://devtools/client/netmonitor/src/constants.js"); + +const CONTENT_MIME_TYPE_ABBREVIATIONS = { + ecmascript: "js", + javascript: "js", + "x-javascript": "js", +}; + +/** + * Extracts any urlencoded form data sections (e.g. "?foo=bar&baz=42") from a + * POST request. + * + * @param {object} headers - the "requestHeaders". + * @param {object} uploadHeaders - the "requestHeadersFromUploadStream". + * @param {object} postData - the "requestPostData". + * @return {array} a promise list that is resolved with the extracted form data. + */ +async function getFormDataSections( + headers, + uploadHeaders, + postData, + getLongString +) { + const formDataSections = []; + + const requestHeaders = headers.headers; + const payloadHeaders = uploadHeaders ? uploadHeaders.headers : []; + const allHeaders = [...payloadHeaders, ...requestHeaders]; + + const contentTypeHeader = allHeaders.find(e => { + return e.name.toLowerCase() == "content-type"; + }); + + const contentTypeLongString = contentTypeHeader + ? contentTypeHeader.value + : ""; + + const contentType = await getLongString(contentTypeLongString); + + if (contentType && contentType.includes("x-www-form-urlencoded")) { + const postDataLongString = postData.postData.text; + const text = await getLongString(postDataLongString); + + for (const section of text.trim().split(/\r\n|\r|\n/)) { + // Before displaying it, make sure this section of the POST data + // isn't a line containing upload stream headers. + if (payloadHeaders.every(header => !section.startsWith(header.name))) { + formDataSections.push(section); + } + } + } + + return formDataSections; +} + +/** + * Fetch headers full content from actor server + * + * @param {object} headers - a object presents headers data + * @return {object} a headers object with updated content payload + */ +async function fetchHeaders(headers, getLongString) { + for (const { value } of headers.headers) { + headers.headers.value = await getLongString(value); + } + return headers; +} + +/** + * Fetch network event update packets from actor server + * Expect to fetch a couple of network update packets from a given request. + * + * @param {function} requestData - requestData function for lazily fetch data + * @param {object} request - request object + * @param {array} updateTypes - a list of network event update types + */ +function fetchNetworkUpdatePacket(requestData, request, updateTypes) { + const promises = []; + if (request) { + updateTypes.forEach(updateType => { + // Only stackTrace will be handled differently + if (updateType === "stackTrace") { + if (request.cause.stacktraceAvailable && !request.stacktrace) { + promises.push(requestData(request.id, updateType)); + } + return; + } + + if (request[`${updateType}Available`] && !request[updateType]) { + promises.push(requestData(request.id, updateType)); + } + }); + } + + return Promise.all(promises); +} + +/** + * Form a data: URI given a mime type, encoding, and some text. + * + * @param {string} mimeType - mime type + * @param {string} encoding - encoding to use; if not set, the + * text will be base64-encoded. + * @param {string} text - text of the URI. + * @return {string} a data URI + */ +function formDataURI(mimeType, encoding, text) { + if (!encoding) { + encoding = "base64"; + text = btoa(unescape(encodeURIComponent(text))); + } + return "data:" + mimeType + ";" + encoding + "," + text; +} + +/** + * Write out a list of headers into a chunk of text + * + * @param {array} headers - array of headers info { name, value } + * @param {string} preHeaderText - first line of the headers request/response + * @return {string} list of headers in text format + */ +function writeHeaderText(headers, preHeaderText) { + let result = ""; + if (preHeaderText) { + result += preHeaderText + "\r\n"; + } + result += headers.map(({ name, value }) => name + ": " + value).join("\r\n"); + result += "\r\n\r\n"; + return result; +} + +/** + * Decode base64 string. + * + * @param {string} url - a string + * @return {string} decoded string + */ +function decodeUnicodeBase64(string) { + try { + return decodeURIComponent(atob(string)); + } catch (err) { + // Ignore error and return input string directly. + } + return string; +} + +/** + * Helper for getting an abbreviated string for a mime type. + * + * @param {string} mimeType - mime type + * @return {string} abbreviated mime type + */ +function getAbbreviatedMimeType(mimeType) { + if (!mimeType) { + return ""; + } + const abbrevType = (mimeType.split(";")[0].split("/")[1] || "").split("+")[0]; + return CONTENT_MIME_TYPE_ABBREVIATIONS[abbrevType] || abbrevType; +} + +/** + * Helpers for getting a filename from a mime type. + * + * @param {string} baseNameWithQuery - unicode basename and query of a url + * @return {string} unicode filename portion of a url + */ +function getFileName(baseNameWithQuery) { + const basename = baseNameWithQuery && baseNameWithQuery.split("?")[0]; + return basename && basename.includes(".") ? basename : null; +} + +/** + * Helpers for retrieving a URL object from a string + * + * @param {string} url - unvalidated url string + * @return {URL} The URL object + */ +function getUrl(url) { + try { + return new URL(url); + } catch (err) { + return null; + } +} + +/** + * Helpers for retrieving the value of a URL object property + * + * @param {string} input - unvalidated url string + * @param {string} string - desired property in the URL object + * @return {string} unicode query of a url + */ +function getUrlProperty(input, property) { + const url = getUrl(input); + return url?.[property] ? url[property] : ""; +} + +/** + * Helpers for getting the last portion of a url. + * For example helper returns "basename" from http://domain.com/path/basename + * If basename portion is empty, it returns the url pathname. + * + * @param {string} input - unvalidated url string + * @return {string} unicode basename of a url + */ +function getUrlBaseName(url) { + const pathname = getUrlProperty(url, "pathname"); + return getUnicodeUrlPath(pathname.replace(/\S*\//, "") || pathname || "/"); +} + +/** + * Helpers for getting the query portion of a url. + * + * @param {string} url - unvalidated url string + * @return {string} unicode query of a url + */ +function getUrlQuery(url) { + return getUrlProperty(url, "search").replace(/^\?/, ""); +} + +/** + * Helpers for getting unicode name and query portions of a url. + * + * @param {string} url - unvalidated url string + * @return {string} unicode basename and query portions of a url + */ +function getUrlBaseNameWithQuery(url) { + const basename = getUrlBaseName(url); + const search = getUrlProperty(url, "search"); + return basename + getUnicodeUrlPath(search); +} + +/** + * Helpers for getting hostname portion of an URL. + * + * @param {string} url - unvalidated url string + * @return {string} unicode hostname of a url + */ +function getUrlHostName(url) { + return getUrlProperty(url, "hostname"); +} + +/** + * Helpers for getting host portion of an URL. + * + * @param {string} url - unvalidated url string + * @return {string} unicode host of a url + */ +function getUrlHost(url) { + return getUrlProperty(url, "host"); +} + +/** + * Helpers for getting the shceme portion of a url. + * For example helper returns "http" from http://domain.com/path/basename + * + * @param {string} url - unvalidated url string + * @return {string} string scheme of a url + */ +function getUrlScheme(url) { + const protocol = getUrlProperty(url, "protocol"); + return protocol.replace(":", "").toLowerCase(); +} + +/** + * Extract several details fields from a URL at once. + */ +function getUrlDetails(url) { + const baseNameWithQuery = getUrlBaseNameWithQuery(url); + let host = getUrlHost(url); + const hostname = getUrlHostName(url); + const unicodeUrl = getUnicodeUrl(url); + const scheme = getUrlScheme(url); + + // If the hostname contains unreadable ASCII characters, we need to do the + // following two steps: + // 1. Converting the unreadable hostname to a readable Unicode domain name. + // For example, converting xn--g6w.xn--8pv into a Unicode domain name. + // 2. Replacing the unreadable hostname portion in the `host` with the + // readable hostname. + // For example, replacing xn--g6w.xn--8pv:8000 with [Unicode domain]:8000 + // After finishing the two steps, we get a readable `host`. + const unicodeHostname = getUnicodeHostname(hostname); + if (unicodeHostname !== hostname) { + host = host.replace(hostname, unicodeHostname); + } + + // Mark local hosts specially, where "local" is as defined in the W3C + // spec for secure contexts. + // http://www.w3.org/TR/powerful-features/ + // + // * If the name falls under 'localhost' + // * If the name is an IPv4 address within 127.0.0.0/8 + // * If the name is an IPv6 address within ::1/128 + // + // IPv6 parsing is a little sloppy; it assumes that the address has + // been validated before it gets here. + const isLocal = + hostname.match(/(.+\.)?localhost$/) || + hostname.match(/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}/) || + hostname.match(/\[[0:]+1\]/); + + return { + baseNameWithQuery, + host, + scheme, + unicodeUrl, + isLocal, + url, + }; +} + +/** + * Parse a url's query string into its components + * + * @param {string} query - query string of a url portion + * @return {array} array of query params { name, value } + */ +function parseQueryString(query) { + if (!query) { + return null; + } + return query + .replace(/^[?&]/, "") + .split("&") + .map(e => { + const param = e.split("="); + return { + name: param[0] ? getUnicodeUrlPath(param[0].replace(/\+/g, " ")) : "", + value: param[1] + ? getUnicodeUrlPath(param.slice(1).join("=").replace(/\+/g, " ")) + : "", + }; + }); +} + +/** + * Parse a string of formdata sections into its components + * + * @param {string} sections - sections of formdata joined by & + * @return {array} array of formdata params { name, value } + */ +function parseFormData(sections) { + if (!sections) { + return []; + } + + return sections + .replace(/^&/, "") + .split("&") + .map(e => { + const param = e.split("="); + return { + name: param[0] ? getUnicodeUrlPath(param[0]) : "", + value: param[1] ? getUnicodeUrlPath(param[1]) : "", + }; + }); +} + +/** + * Reduces an IP address into a number for easier sorting + * + * @param {string} ip - IP address to reduce + * @return {number} the number representing the IP address + */ +function ipToLong(ip) { + if (!ip) { + // Invalid IP + return -1; + } + + let base; + let octets = ip.split("."); + + if (octets.length === 4) { + // IPv4 + base = 10; + } else if (ip.includes(":")) { + // IPv6 + const numberOfZeroSections = + 8 - ip.replace(/^:+|:+$/g, "").split(/:+/g).length; + octets = ip + .replace("::", `:${"0:".repeat(numberOfZeroSections)}`) + .replace(/^:|:$/g, "") + .split(":"); + base = 16; + } else { + // Invalid IP + return -1; + } + return octets + .map((val, ix, arr) => { + return parseInt(val, base) * Math.pow(256, arr.length - 1 - ix); + }) + .reduce((sum, val) => { + return sum + val; + }, 0); +} + +/** + * Compare two objects on a subset of their properties + */ +function propertiesEqual(props, item1, item2) { + return item1 === item2 || props.every(p => item1[p] === item2[p]); +} + +/** + * Calculate the start time of a request, which is the time from start + * of 1st request until the start of this request. + * + * Without a firstRequestStartedMs argument the wrong time will be returned. + * However, it can be omitted when comparing two start times and neither supplies + * a firstRequestStartedMs. + */ +function getStartTime(item, firstRequestStartedMs = 0) { + return item.startedMs - firstRequestStartedMs; +} + +/** + * Calculate the end time of a request, which is the time from start + * of 1st request until the end of this response. + * + * Without a firstRequestStartedMs argument the wrong time will be returned. + * However, it can be omitted when comparing two end times and neither supplies + * a firstRequestStartedMs. + */ +function getEndTime(item, firstRequestStartedMs = 0) { + const { startedMs, totalTime } = item; + return startedMs + totalTime - firstRequestStartedMs; +} + +/** + * Calculate the response time of a request, which is the time from start + * of 1st request until the beginning of download of this response. + * + * Without a firstRequestStartedMs argument the wrong time will be returned. + * However, it can be omitted when comparing two response times and neither supplies + * a firstRequestStartedMs. + */ +function getResponseTime(item, firstRequestStartedMs = 0) { + const { startedMs, totalTime, eventTimings = { timings: {} } } = item; + return ( + startedMs + totalTime - firstRequestStartedMs - eventTimings.timings.receive + ); +} + +/** + * Format the protocols used by the request. + */ +function getFormattedProtocol(item) { + const { httpVersion = "", responseHeaders = { headers: [] } } = item; + const protocol = [httpVersion]; + responseHeaders.headers.some(h => { + if (h.hasOwnProperty("name") && h.name.toLowerCase() === "x-firefox-spdy") { + /** + * First we make sure h.value is defined and not an empty string. + * Then check that HTTP version and x-firefox-spdy == "http/1.1". + * If not, check that HTTP version and x-firefox-spdy have the same + * numeric value when of the forms "http/<x>" and "h<x>" respectively. + * If not, will push to protocol the non-standard x-firefox-spdy value. + * + * @see https://bugzilla.mozilla.org/show_bug.cgi?id=1501357 + */ + if (h.value !== undefined && h.value.length) { + if ( + h.value.toLowerCase() !== "http/1.1" || + protocol[0].toLowerCase() !== "http/1.1" + ) { + if ( + parseFloat(h.value.toLowerCase().split("")[1]) !== + parseFloat(protocol[0].toLowerCase().split("/")[1]) + ) { + protocol.push(h.value); + return true; + } + } + } + } + return false; + }); + return protocol.join("+"); +} + +/** + * Get the value of a particular response header, or null if not + * present. + */ +function getResponseHeader(item, header) { + const { responseHeaders } = item; + if (!responseHeaders || !responseHeaders.headers.length) { + return null; + } + header = header.toLowerCase(); + for (const responseHeader of responseHeaders.headers) { + if (responseHeader.name.toLowerCase() == header) { + return responseHeader.value; + } + } + return null; +} + +/** + * Get the value of a particular request header, or null if not + * present. + */ +function getRequestHeader(item, header) { + const { requestHeaders } = item; + if (!requestHeaders || !requestHeaders.headers.length) { + return null; + } + header = header.toLowerCase(); + for (const requestHeader of requestHeaders.headers) { + if (requestHeader.name.toLowerCase() == header) { + return requestHeader.value; + } + } + return null; +} + +/** + * Extracts any urlencoded form data sections from a POST request. + */ +async function updateFormDataSections(props) { + const { connector, request = {}, updateRequest } = props; + let { + id, + formDataSections, + requestHeaders, + requestHeadersAvailable, + requestHeadersFromUploadStream, + requestPostData, + requestPostDataAvailable, + } = request; + + if (requestHeadersAvailable && !requestHeaders) { + requestHeaders = await connector.requestData(id, "requestHeaders"); + } + + if (requestPostDataAvailable && !requestPostData) { + requestPostData = await connector.requestData(id, "requestPostData"); + } + + if ( + !formDataSections && + requestHeaders && + requestPostData && + requestHeadersFromUploadStream + ) { + formDataSections = await getFormDataSections( + requestHeaders, + requestHeadersFromUploadStream, + requestPostData, + connector.getLongString + ); + + updateRequest(request.id, { formDataSections }, true); + } +} + +/** + * This helper function helps to resolve the full payload of a message + * that is wrapped in a LongStringActor object. + */ +async function getMessagePayload(payload, getLongString) { + const result = await getLongString(payload); + return result; +} + +/** + * This helper function is used for additional processing of + * incoming network update packets. It makes sure the only valid + * update properties and the values are correct. + * It's used by Network and Console panel reducers. + * @param {object} update + * The new update payload + * @param {object} request + * The current request in the state + */ +function processNetworkUpdates(update) { + const newRequest = {}; + for (const [key, value] of Object.entries(update)) { + if (UPDATE_PROPS.includes(key)) { + newRequest[key] = value; + if (key == "requestPostData") { + newRequest.requestHeadersFromUploadStream = value.uploadHeaders; + } + } + } + return newRequest; +} + +/** + * This method checks that the response is base64 encoded by + * comparing these 2 values: + * 1. The original response + * 2. The value of doing a base64 decode on the + * response and then base64 encoding the result. + * If the values are different or an error is thrown, + * the method will return false. + */ +function isBase64(payload) { + try { + return btoa(atob(payload)) == payload; + } catch (err) { + return false; + } +} + +/** + * Checks if the payload is of JSON type. + * This function also handles JSON with XSSI-escaping characters by stripping them + * and returning the stripped chars in the strippedChars property + * This function also handles Base64 encoded JSON. + * @returns {Object} shape: + * {Object} json: parsed JSON object + * {Error} error: JSON parsing error + * {string} strippedChars: XSSI stripped chars removed from JSON payload + */ +function parseJSON(payloadUnclean) { + let json; + const jsonpRegex = /^\s*([\w$]+)\s*\(\s*([^]*)\s*\)\s*;?\s*$/; + const [, jsonpCallback, jsonp] = payloadUnclean.match(jsonpRegex) || []; + if (jsonpCallback && jsonp) { + let error; + try { + json = parseJSON(jsonp).json; + } catch (err) { + error = err; + } + return { json, error, jsonpCallback }; + } + + let { payload, strippedChars, error } = removeXSSIString(payloadUnclean); + + try { + json = JSON.parse(payload); + } catch (err) { + if (isBase64(payload)) { + try { + json = JSON.parse(atob(payload)); + } catch (err64) { + error = err64; + } + } else { + error = err; + } + } + + // Do not present JSON primitives (e.g. boolean, strings in quotes, numbers) + // as JSON expandable tree. + if (!error) { + if (typeof json !== "object") { + return {}; + } + } + return { + json, + error, + strippedChars, + }; +} + +/** + * Removes XSSI prevention sequences from JSON payloads + * @param {string} payloadUnclean: JSON payload that may or may have a + * XSSI prevention sequence + * @returns {Object} Shape: + * {string} payload: the JSON witht the XSSI prevention sequence removed + * {string} strippedChars: XSSI string that was removed, null if no XSSI + * prevention sequence was found + * {Error} error: error attempting to strip XSSI prevention sequence + */ +function removeXSSIString(payloadUnclean) { + // Regex that finds the XSSI protection sequences )]}'\n for(;;); and while(1); + const xssiRegex = /(^\)\]\}',?\n)|(^for ?\(;;\);?)|(^while ?\(1\);?)/; + let payload, strippedChars, error; + const xssiRegexMatch = payloadUnclean.match(xssiRegex); + + // Remove XSSI string if there was one found + if (xssiRegexMatch?.length > 0) { + const xssiLen = xssiRegexMatch[0].length; + try { + // substring the payload by the length of the XSSI match to remove it + // and save the match to report + payload = payloadUnclean.substring(xssiLen); + strippedChars = xssiRegexMatch[0]; + } catch (err) { + error = err; + payload = payloadUnclean; + } + } else { + // if there was no XSSI match just return the raw payload + payload = payloadUnclean; + } + return { + payload, + strippedChars, + error, + }; +} + +/** + * Computes the request headers of an HTTP request + * + * @param {string} method: request method + * @param {string} httpVersion: request http version + * @param {object} requestHeaders: request headers + * @param {object} urlDetails: request url details + * + * @return {string} the request headers + */ +function getRequestHeadersRawText( + method, + httpVersion, + requestHeaders, + urlDetails +) { + const url = new URL(urlDetails.url); + const path = url ? `${url.pathname}${url.search}` : "<unknown>"; + const preHeaderText = `${method} ${path} ${httpVersion}`; + return writeHeaderText(requestHeaders.headers, preHeaderText).trim(); +} + +module.exports = { + decodeUnicodeBase64, + getFormDataSections, + fetchHeaders, + fetchNetworkUpdatePacket, + formDataURI, + writeHeaderText, + getAbbreviatedMimeType, + getFileName, + getEndTime, + getFormattedProtocol, + getMessagePayload, + getRequestHeader, + getResponseHeader, + getResponseTime, + getStartTime, + getUrlBaseName, + getUrlBaseNameWithQuery, + getUrlDetails, + getUrlHost, + getUrlHostName, + getUrlQuery, + getUrlScheme, + parseQueryString, + parseFormData, + updateFormDataSections, + processNetworkUpdates, + propertiesEqual, + ipToLong, + parseJSON, + getRequestHeadersRawText, +}; diff --git a/devtools/client/netmonitor/src/utils/sort-predicates.js b/devtools/client/netmonitor/src/utils/sort-predicates.js new file mode 100644 index 0000000000..839a37d00e --- /dev/null +++ b/devtools/client/netmonitor/src/utils/sort-predicates.js @@ -0,0 +1,319 @@ +/* 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 { + getAbbreviatedMimeType, + getEndTime, + getResponseTime, + getResponseHeader, + getStartTime, + ipToLong, +} = require("resource://devtools/client/netmonitor/src/utils/request-utils.js"); +const { + RESPONSE_HEADERS, +} = require("resource://devtools/client/netmonitor/src/constants.js"); +const { + getUrlBaseName, +} = require("resource://devtools/client/netmonitor/src/utils/request-utils.js"); + +/** + * Predicates used when sorting items. + * + * @param object first + * The first item used in the comparison. + * @param object second + * The second item used in the comparison. + * @return number + * <0 to sort first to a lower index than second + * =0 to leave first and second unchanged with respect to each other + * >0 to sort second to a lower index than first + */ + +function compareValues(first, second) { + if (first === second) { + return 0; + } + return first > second ? 1 : -1; +} + +function waterfall(first, second) { + const result = compareValues(first.startedMs, second.startedMs); + return result || compareValues(first.id, second.id); +} + +function priority(first, second) { + const result = compareValues(first.priority, second.priority); + return result || waterfall(first, second); +} + +function status(first, second) { + const result = compareValues(getStatusValue(first), getStatusValue(second)); + return result || waterfall(first, second); +} + +function method(first, second) { + const result = compareValues(first.method, second.method); + return result || waterfall(first, second); +} + +function file(first, second) { + const firstUrl = first.urlDetails.baseNameWithQuery.toLowerCase(); + const secondUrl = second.urlDetails.baseNameWithQuery.toLowerCase(); + const result = compareValues(firstUrl, secondUrl); + return result || waterfall(first, second); +} + +function url(first, second) { + const firstUrl = first.url.toLowerCase(); + const secondUrl = second.url.toLowerCase(); + const result = compareValues(firstUrl, secondUrl); + return result || waterfall(first, second); +} + +function protocol(first, second) { + const result = compareValues(first.httpVersion, second.httpVersion); + return result || waterfall(first, second); +} + +function scheme(first, second) { + const result = compareValues( + first.urlDetails.scheme, + second.urlDetails.scheme + ); + return result || waterfall(first, second); +} + +function startTime(first, second) { + const result = compareValues(getStartTime(first), getStartTime(second)); + return result || waterfall(first, second); +} + +function endTime(first, second) { + const result = compareValues(getEndTime(first), getEndTime(second)); + return result || waterfall(first, second); +} + +function responseTime(first, second) { + const result = compareValues(getResponseTime(first), getResponseTime(second)); + return result || waterfall(first, second); +} + +function duration(first, second) { + const result = compareValues(first.totalTime, second.totalTime); + return result || waterfall(first, second); +} + +function latency(first, second) { + const { eventTimings: firstEventTimings = { timings: {} } } = first; + const { eventTimings: secondEventTimings = { timings: {} } } = second; + const result = compareValues( + firstEventTimings.timings.wait, + secondEventTimings.timings.wait + ); + return result || waterfall(first, second); +} + +function compareHeader(header, first, second) { + const firstValue = getResponseHeader(first, header) || ""; + const secondValue = getResponseHeader(second, header) || ""; + + let result; + + switch (header) { + case "Content-Length": { + result = compareValues( + parseInt(firstValue, 10) || 0, + parseInt(secondValue, 10) || 0 + ); + break; + } + case "Last-Modified": { + result = compareValues( + new Date(firstValue).valueOf() || -1, + new Date(secondValue).valueOf() || -1 + ); + break; + } + default: { + result = compareValues(firstValue, secondValue); + break; + } + } + + return result || waterfall(first, second); +} + +const responseHeaders = RESPONSE_HEADERS.reduce( + (acc, header) => + Object.assign(acc, { + [header]: (first, second) => compareHeader(header, first, second), + }), + {} +); + +function domain(first, second) { + const firstDomain = first.urlDetails.host.toLowerCase(); + const secondDomain = second.urlDetails.host.toLowerCase(); + const result = compareValues(firstDomain, secondDomain); + return result || waterfall(first, second); +} + +function remoteip(first, second) { + const firstIP = ipToLong(first.remoteAddress); + const secondIP = ipToLong(second.remoteAddress); + const result = compareValues(firstIP, secondIP); + return result || waterfall(first, second); +} + +function cause(first, second) { + const firstCause = first.cause.type; + const secondCause = second.cause.type; + const result = compareValues(firstCause, secondCause); + return result || waterfall(first, second); +} + +function initiator(first, second) { + const firstCause = first.cause.type; + const secondCause = second.cause.type; + + let firstInitiator = ""; + let firstInitiatorLineNumber = 0; + + if (first.cause.lastFrame) { + firstInitiator = getUrlBaseName(first.cause.lastFrame.filename); + firstInitiatorLineNumber = first.cause.lastFrame.lineNumber; + } + + let secondInitiator = ""; + let secondInitiatorLineNumber = 0; + + if (second.cause.lastFrame) { + secondInitiator = getUrlBaseName(second.cause.lastFrame.filename); + secondInitiatorLineNumber = second.cause.lastFrame.lineNumber; + } + + let result; + // if both initiators don't have a stack trace, compare their causes + if (!firstInitiator && !secondInitiator) { + result = compareValues(firstCause, secondCause); + } else if (!firstInitiator || !secondInitiator) { + // if one initiator doesn't have a stack trace but the other does, former should precede the latter + result = compareValues(firstInitiatorLineNumber, secondInitiatorLineNumber); + } else { + result = compareValues(firstInitiator, secondInitiator); + if (result === 0) { + result = compareValues( + firstInitiatorLineNumber, + secondInitiatorLineNumber + ); + } + } + + return result || waterfall(first, second); +} + +function setCookies(first, second) { + let { responseCookies: firstResponseCookies = { cookies: [] } } = first; + let { responseCookies: secondResponseCookies = { cookies: [] } } = second; + firstResponseCookies = firstResponseCookies.cookies || firstResponseCookies; + secondResponseCookies = + secondResponseCookies.cookies || secondResponseCookies; + const result = compareValues( + firstResponseCookies.length, + secondResponseCookies.length + ); + return result || waterfall(first, second); +} + +function cookies(first, second) { + let { requestCookies: firstRequestCookies = { cookies: [] } } = first; + let { requestCookies: secondRequestCookies = { cookies: [] } } = second; + firstRequestCookies = firstRequestCookies.cookies || firstRequestCookies; + secondRequestCookies = secondRequestCookies.cookies || secondRequestCookies; + const result = compareValues( + firstRequestCookies.length, + secondRequestCookies.length + ); + return result || waterfall(first, second); +} + +function type(first, second) { + const firstType = getAbbreviatedMimeType(first.mimeType).toLowerCase(); + const secondType = getAbbreviatedMimeType(second.mimeType).toLowerCase(); + const result = compareValues(firstType, secondType); + return result || waterfall(first, second); +} + +function getStatusValue(item) { + let value; + if (item.blockedReason) { + value = typeof item.blockedReason == "number" ? -item.blockedReason : -1000; + } else if (item.status == null) { + value = -2; + } else { + value = item.status; + } + return value; +} + +function getTransferedSizeValue(item) { + let value; + if (item.blockedReason) { + // Also properly group/sort various blocked reasons. + value = typeof item.blockedReason == "number" ? -item.blockedReason : -1000; + } else if (item.fromCache || item.status === "304") { + value = -2; + } else if (item.fromServiceWorker) { + value = -3; + } else if (typeof item.transferredSize == "number") { + value = item.transferredSize; + if (item.isRacing && typeof item.isRacing == "boolean") { + value = -4; + } + } else if (item.transferredSize === null) { + value = -5; + } + return value; +} + +function transferred(first, second) { + const result = compareValues( + getTransferedSizeValue(first), + getTransferedSizeValue(second) + ); + return result || waterfall(first, second); +} + +function contentSize(first, second) { + const result = compareValues(first.contentSize, second.contentSize); + return result || waterfall(first, second); +} + +const sorters = { + status, + method, + domain, + file, + protocol, + scheme, + cookies, + setCookies, + remoteip, + cause, + initiator, + type, + transferred, + contentSize, + startTime, + endTime, + responseTime, + duration, + latency, + waterfall, + url, + priority, +}; +exports.Sorters = Object.assign(sorters, responseHeaders); diff --git a/devtools/client/netmonitor/src/utils/sort-utils.js b/devtools/client/netmonitor/src/utils/sort-utils.js new file mode 100644 index 0000000000..a7f49417e8 --- /dev/null +++ b/devtools/client/netmonitor/src/utils/sort-utils.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"; + +/** + * Sorts object by keys in alphabetical order + * If object has nested children, it sorts the child-elements also by keys + * @param {object} which should be sorted by keys in alphabetical order + */ +function sortObjectKeys(object) { + if (object == null) { + return null; + } + + if (Array.isArray(object)) { + for (let i = 0; i < object.length; i++) { + if (typeof object[i] === "object") { + object[i] = sortObjectKeys(object[i]); + } + } + return object; + } + + return Object.keys(object) + .sort(function (left, right) { + return left.toLowerCase().localeCompare(right.toLowerCase()); + }) + .reduce((acc, key) => { + if (typeof object[key] === "object") { + acc[key] = sortObjectKeys(object[key]); + } else { + acc[key] = object[key]; + } + return acc; + }, Object.create(null)); +} + +module.exports = { + sortObjectKeys, +}; diff --git a/devtools/client/netmonitor/src/utils/tooltips.js b/devtools/client/netmonitor/src/utils/tooltips.js new file mode 100644 index 0000000000..1f35346353 --- /dev/null +++ b/devtools/client/netmonitor/src/utils/tooltips.js @@ -0,0 +1,18 @@ +/* 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"; + +/** + * Returns first 128 characters of value for use as a tooltip. + * @param object + * @returns {*} + */ +function limitTooltipLength(object) { + return object.length > 128 ? object.substring(0, 128) + "…" : object; +} + +module.exports = { + limitTooltipLength, +}; |