desktop/plugins/public/network/utils.tsx (233 lines of code) (raw):
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import pako from 'pako';
import {Request, Header, ResponseInfo} from './types';
import {Base64} from 'js-base64';
export function getHeaderValue(
headers: Array<Header> | undefined,
key: string,
): string {
if (!headers) {
return '';
}
for (const header of headers) {
if (header.key.toLowerCase() === key.toLowerCase()) {
return header.value;
}
}
return '';
}
// Matches `application/json` and `application/vnd.api.v42+json` (see https://jsonapi.org/#mime-types)
const jsonContentTypeRegex = new RegExp('application/(json|.+\\+json)');
const binaryContentType =
/^(application\/(zip|octet-stream|pdf))|(video|audio)|(image\/(png|webp|jpeg|gif|avif))$/;
export function isTextual(
headers?: Array<Header>,
body?: Uint8Array | string,
): boolean {
const contentType = getHeaderValue(headers, 'Content-Type');
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types
if (contentType) {
if (
contentType.startsWith('text/') ||
contentType.startsWith('application/x-www-form-urlencoded') ||
jsonContentTypeRegex.test(contentType) ||
contentType.startsWith('multipart/') ||
contentType.startsWith('message/') ||
contentType.startsWith('image/svg') ||
contentType.startsWith('application/xhtml+xml') ||
contentType.startsWith('application/xml')
) {
return true;
}
if (binaryContentType.test(contentType)) {
return false;
}
}
if (
(body instanceof Buffer || body instanceof Uint8Array) &&
isValidUtf8(body)
) {
return true;
}
return false;
}
function isValidUtf8(data: Uint8Array) {
if (data[0] === 0xef && data[1] === 0xbb && data[2] === 0xbf) {
return true; // valid utf8 BOM
}
// From https://weblog.rogueamoeba.com/2017/02/27/javascript-correctly-converting-a-byte-array-to-a-utf-8-string/
const extraByteMap = [1, 1, 1, 1, 2, 2, 3, 0];
const count = data.length;
for (let index = 0; index < count; ) {
let ch = data[index++];
if (ch & 0x80) {
let extra = extraByteMap[(ch >> 3) & 0x07];
if (!(ch & 0x40) || !extra || index + extra > count) return false;
ch &= 0x3f >> extra;
for (; extra > 0; extra -= 1) {
const chx = data[index++];
if ((chx & 0xc0) != 0x80) return false;
ch = (ch << 6) | (chx & 0x3f);
}
}
}
return true;
}
export function decodeBody(
headers?: Array<Header>,
data?: string | null,
): string | undefined | Uint8Array {
if (!data) {
return undefined;
}
try {
const isGzip = getHeaderValue(headers, 'Content-Encoding') === 'gzip';
if (isGzip) {
try {
// The request is gzipped, so convert the raw bytes back to base64 first.
const dataArr = Base64.toUint8Array(data);
// then inflate.
return isTextual(headers, dataArr)
? // pako will detect the BOM headers and return a proper utf-8 string right away
pako.inflate(dataArr, {to: 'string'})
: pako.inflate(dataArr);
} catch (e) {
// on iOS, the stream send to flipper is already inflated, so the content-encoding will not
// match the actual data anymore, and we should skip inflating.
// In that case, we intentionally fall-through
if (!('' + e).includes('incorrect header check')) {
throw e;
}
}
}
// If this is not a gzipped request, assume we are interested in a proper utf-8 string.
// - If the raw binary data in is needed, in base64 form, use data directly
// - either directly use data (for example)
const bytes = Base64.toUint8Array(data);
if (isTextual(headers, bytes)) {
return Base64.decode(data);
} else {
return bytes;
}
} catch (e) {
console.warn(
`Flipper failed to decode request/response body (size: ${data.length}): ${e}`,
);
return undefined;
}
}
export function convertRequestToCurlCommand(
request: Pick<Request, 'method' | 'url' | 'requestHeaders' | 'requestData'>,
): string {
let command: string = `curl -v -X ${request.method}`;
command += ` ${escapedString(request.url)}`;
// Add headers
request.requestHeaders.forEach((header: Header) => {
const headerStr = `${header.key}: ${header.value}`;
command += ` -H ${escapedString(headerStr)}`;
});
if (typeof request.requestData === 'string') {
command += ` -d ${escapedString(request.requestData)}`;
}
return command;
}
export function bodyAsString(body: undefined | string | Uint8Array): string {
if (body == undefined) {
return '(empty)';
}
if (body instanceof Uint8Array) {
return '(binary data)';
}
return body;
}
export function bodyAsBinary(
body: undefined | string | Uint8Array,
): Uint8Array | undefined {
if (body instanceof Uint8Array) {
return body;
}
return undefined;
}
function escapeCharacter(x: string) {
const code = x.charCodeAt(0);
return code < 16 ? '\\u0' + code.toString(16) : '\\u' + code.toString(16);
}
const needsEscapingRegex = /[\u0000-\u001f\u007f-\u009f!]/g;
// Escape util function, inspired by Google DevTools. Works only for POSIX
// based systems.
function escapedString(str: string) {
if (needsEscapingRegex.test(str) || str.includes("'")) {
return (
"$'" +
str
.replace(/\\/g, '\\\\')
.replace(/\'/g, "\\'")
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r')
.replace(needsEscapingRegex, escapeCharacter) +
"'"
);
}
// Simply use singly quoted string.
return "'" + str + "'";
}
export function getResponseLength(response: ResponseInfo): number {
const lengthString = response.headers
? getHeaderValue(response.headers, 'content-length')
: undefined;
if (lengthString) {
return parseInt(lengthString, 10);
} else if (response.data) {
return Buffer.byteLength(response.data, 'base64');
}
return 0;
}
export function getRequestLength(request: Request): number {
const lengthString = request.requestHeaders
? getHeaderValue(request.requestHeaders, 'content-length')
: undefined;
if (lengthString) {
return parseInt(lengthString, 10);
} else if (request.requestData) {
return Buffer.byteLength(request.requestData, 'base64');
}
return 0;
}
export function formatDuration(duration: number | undefined) {
if (typeof duration === 'number') return duration + 'ms';
return '';
}
export function formatBytes(count: number | undefined): string {
if (typeof count !== 'number') {
return '';
}
if (count > 1024 * 1024) {
return (count / (1024.0 * 1024)).toFixed(1) + ' MB';
}
if (count > 1024) {
return (count / 1024.0).toFixed(1) + ' kB';
}
return count + ' B';
}
export function formatStatus(status: number | undefined) {
return status ? '' + status : '';
}
export function requestsToText(requests: Request[]): string {
const request = requests[0];
if (!request || !request.url) {
return '<empty request>';
}
let copyText = `# HTTP request for ${request.domain} (ID: ${request.id})
## Request
HTTP ${request.method} ${request.url}
${request.requestHeaders
.map(
({key, value}: {key: string; value: string}): string =>
`${key}: ${String(value)}`,
)
.join('\n')}`;
// TODO: we want decoding only for non-binary data! See D23403095
if (request.requestData) {
copyText += `\n\n${request.requestData}`;
}
if (request.status) {
copyText += `
## Response
HTTP ${request.status} ${request.reason}
${
request.responseHeaders
?.map(
({key, value}: {key: string; value: string}): string =>
`${key}: ${String(value)}`,
)
.join('\n') ?? ''
}`;
}
if (request.responseData) {
copyText += `\n\n${request.responseData}`;
}
return copyText;
}