Skip to content

Commit 2a7d07e

Browse files
authored
Merge branch 'main' into remove-transition-group
2 parents 7018717 + 2486c95 commit 2a7d07e

8 files changed

Lines changed: 648 additions & 531 deletions

File tree

src/actions/receive-profile.ts

Lines changed: 16 additions & 271 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22
* License, v. 2.0. If a copy of the MPL was not distributed with this
33
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
44
import { oneLine } from 'common-tags';
5+
import {
6+
fetchProfile,
7+
getProfileUrlForHash,
8+
type ProfileOrZip,
9+
deduceContentType,
10+
extractJsonFromArrayBuffer,
11+
} from 'firefox-profiler/utils/profile-fetch';
512
import queryString from 'query-string';
613
import type JSZip from 'jszip';
714
import {
@@ -20,10 +27,8 @@ import {
2027
} from 'firefox-profiler/profile-logic/symbolication';
2128
import * as MozillaSymbolicationAPI from 'firefox-profiler/profile-logic/mozilla-symbolication-api';
2229
import { mergeProfilesForDiffing } from 'firefox-profiler/profile-logic/merge-compare';
23-
import { decompress, isGzip } from 'firefox-profiler/utils/gz';
2430
import { expandUrl } from 'firefox-profiler/utils/shorten-url';
2531
import { TemporaryError } from 'firefox-profiler/utils/errors';
26-
import { isLocalURL } from 'firefox-profiler/utils/url';
2732
import {
2833
getSelectedThreadIndexesOrNull,
2934
getGlobalTrackOrder,
@@ -67,7 +72,6 @@ import {
6772
import { setDataSource } from './profile-view';
6873
import { fatalError } from './errors';
6974
import { batchLoadDataUrlIcons } from './icons';
70-
import { GOOGLE_STORAGE_BUCKET } from 'firefox-profiler/app-logic/constants';
7175
import {
7276
determineTimelineType,
7377
hasUsefulSamples,
@@ -547,7 +551,7 @@ async function _unpackGeckoProfileFromBrowser(
547551
// global. This happens especially with tests but could happen in the future
548552
// in Firefox too.
549553
if (Object.prototype.toString.call(profile) === '[object ArrayBuffer]') {
550-
return _extractJsonFromArrayBuffer(profile as ArrayBuffer);
554+
return extractJsonFromArrayBuffer(profile as ArrayBuffer);
551555
}
552556
return profile;
553557
}
@@ -557,9 +561,9 @@ function getSymbolStore(
557561
symbolServerUrl: string,
558562
browserConnection: BrowserConnection | null
559563
): SymbolStore | null {
560-
if (!window.indexedDB) {
561-
// We could be running in a test environment with no indexedDB support. Do not
562-
// return a symbol store in this case.
564+
if (typeof window === 'undefined' || !window.indexedDB) {
565+
// We could be running in a test environment or Node.js with no indexedDB support.
566+
// Do not return a symbol store in this case.
563567
return null;
564568
}
565569

@@ -983,265 +987,6 @@ export function temporaryError(error: TemporaryError): Action {
983987
};
984988
}
985989

986-
function _wait(delayMs: number): Promise<void> {
987-
return new Promise((resolve) => setTimeout(resolve, delayMs));
988-
}
989-
990-
function _loadProbablyFailedDueToSafariLocalhostHTTPRestriction(
991-
url: string,
992-
error: Error
993-
): boolean {
994-
if (!navigator.userAgent.match(/Safari\/\d+\.\d+/)) {
995-
return false;
996-
}
997-
// Check if Safari considers this mixed content.
998-
const parsedUrl = new URL(url);
999-
return (
1000-
error.name === 'TypeError' &&
1001-
parsedUrl.protocol === 'http:' &&
1002-
isLocalURL(parsedUrl) &&
1003-
location.protocol === 'https:'
1004-
);
1005-
}
1006-
1007-
class SafariLocalhostHTTPLoadError extends Error {
1008-
override name = 'SafariLocalhostHTTPLoadError';
1009-
}
1010-
1011-
type FetchProfileArgs = {
1012-
url: string;
1013-
onTemporaryError: (param: TemporaryError) => void;
1014-
// Allow tests to capture the reported error, but normally use console.error.
1015-
reportError?: (...data: Array<any>) => void;
1016-
};
1017-
1018-
type ProfileOrZip =
1019-
| { responseType: 'PROFILE'; profile: unknown }
1020-
| { responseType: 'ZIP'; zip: JSZip };
1021-
1022-
/**
1023-
* Tries to fetch a profile on `url`. If the profile is not found,
1024-
* `onTemporaryError` is called with an appropriate error, we wait 1 second, and
1025-
* then tries again. If we still can't find the profile after 11 tries, the
1026-
* returned promise is rejected with a fatal error.
1027-
* If we can retrieve the profile properly, the returned promise is resolved
1028-
* with the JSON.parsed profile.
1029-
*/
1030-
export async function _fetchProfile(
1031-
args: FetchProfileArgs
1032-
): Promise<ProfileOrZip> {
1033-
const MAX_WAIT_SECONDS = 10;
1034-
let i = 0;
1035-
const { url, onTemporaryError } = args;
1036-
// Allow tests to capture the reported error, but normally use console.error.
1037-
const reportError = args.reportError || console.error;
1038-
1039-
while (true) {
1040-
let response;
1041-
try {
1042-
response = await fetch(url);
1043-
} catch (e) {
1044-
// Case 1: Exception.
1045-
if (_loadProbablyFailedDueToSafariLocalhostHTTPRestriction(url, e)) {
1046-
throw new SafariLocalhostHTTPLoadError();
1047-
}
1048-
throw e;
1049-
}
1050-
1051-
// Case 2: successful answer.
1052-
if (response.ok) {
1053-
return _extractProfileOrZipFromResponse(url, response, reportError);
1054-
}
1055-
1056-
// case 3: unrecoverable error.
1057-
if (response.status !== 403) {
1058-
throw new Error(oneLine`
1059-
Could not fetch the profile on remote server.
1060-
Response was: ${response.status} ${response.statusText}.
1061-
`);
1062-
}
1063-
1064-
// case 4: 403 errors can be transient while a profile is uploaded.
1065-
1066-
if (i++ === MAX_WAIT_SECONDS) {
1067-
// In the last iteration we don't send a temporary error because we'll
1068-
// throw an error right after the while loop.
1069-
break;
1070-
}
1071-
1072-
onTemporaryError(
1073-
new TemporaryError(
1074-
'Profile not found on remote server.',
1075-
{ count: i, total: MAX_WAIT_SECONDS + 1 } // 11 tries during 10 seconds
1076-
)
1077-
);
1078-
1079-
await _wait(1000);
1080-
}
1081-
1082-
throw new Error(oneLine`
1083-
Could not fetch the profile on remote server:
1084-
still not found after ${MAX_WAIT_SECONDS} seconds.
1085-
`);
1086-
}
1087-
1088-
/**
1089-
* Deduce the file type from a url and content type. Third parties can give us
1090-
* arbitrary information, so make sure that we try out best to extract the proper
1091-
* information about it.
1092-
*/
1093-
function _deduceContentType(
1094-
url: string,
1095-
contentType: string | null
1096-
): 'application/json' | 'application/zip' | null {
1097-
if (contentType === 'application/zip' || contentType === 'application/json') {
1098-
return contentType;
1099-
}
1100-
if (url.match(/\.zip$/)) {
1101-
return 'application/zip';
1102-
}
1103-
if (url.match(/\.json/)) {
1104-
return 'application/json';
1105-
}
1106-
return null;
1107-
}
1108-
1109-
/**
1110-
* This function guesses the correct content-type (even if one isn't sent) and then
1111-
* attempts to use the proper method to extract the response.
1112-
*/
1113-
async function _extractProfileOrZipFromResponse(
1114-
url: string,
1115-
response: Response,
1116-
reportError: (...data: Array<any>) => void
1117-
): Promise<ProfileOrZip> {
1118-
const contentType = _deduceContentType(
1119-
url,
1120-
response.headers.get('content-type')
1121-
);
1122-
switch (contentType) {
1123-
case 'application/zip':
1124-
return {
1125-
responseType: 'ZIP',
1126-
zip: await _extractZipFromResponse(response, reportError),
1127-
};
1128-
case 'application/json':
1129-
case null:
1130-
// The content type is null if it is unknown, or an unsupported type. Go ahead
1131-
// and try to process it as a profile.
1132-
return {
1133-
responseType: 'PROFILE',
1134-
profile: await _extractJsonFromResponse(
1135-
response,
1136-
reportError,
1137-
contentType
1138-
),
1139-
};
1140-
default:
1141-
throw assertExhaustiveCheck(contentType);
1142-
}
1143-
}
1144-
1145-
/**
1146-
* Attempt to load a zip file from a third party. This process can fail, so make sure
1147-
* to handle and report the error if it does.
1148-
*/
1149-
async function _extractZipFromResponse(
1150-
response: Response,
1151-
reportError: (...data: Array<any>) => void
1152-
): Promise<JSZip> {
1153-
const buffer = await response.arrayBuffer();
1154-
// Workaround for https://github.com/Stuk/jszip/issues/941
1155-
// When running this code in tests, `buffer` doesn't inherits from _this_
1156-
// realm's ArrayBuffer object, and this breaks JSZip which doesn't account for
1157-
// this case. We workaround the issue by wrapping the buffer in an Uint8Array
1158-
// that comes from this realm.
1159-
const typedBuffer = new Uint8Array(buffer);
1160-
try {
1161-
const { default: JSZip } = await import('jszip');
1162-
const zip = await JSZip.loadAsync(typedBuffer);
1163-
// Catch the error if unable to load the zip.
1164-
return zip;
1165-
} catch (error) {
1166-
const message = 'Unable to open the archive file.';
1167-
reportError(message);
1168-
reportError('Error:', error);
1169-
reportError('Fetch response:', response);
1170-
throw new Error(
1171-
`${message} The full error information has been printed out to the DevTool’s console.`
1172-
);
1173-
}
1174-
}
1175-
1176-
/**
1177-
* Parse JSON from an optionally gzipped array buffer.
1178-
*/
1179-
async function _extractJsonFromArrayBuffer(
1180-
arrayBuffer: ArrayBuffer
1181-
): Promise<unknown> {
1182-
let profileBytes = new Uint8Array(arrayBuffer);
1183-
// Check for the gzip magic number in the header.
1184-
if (isGzip(profileBytes)) {
1185-
profileBytes = await decompress(profileBytes);
1186-
}
1187-
1188-
const textDecoder = new TextDecoder();
1189-
return JSON.parse(textDecoder.decode(profileBytes));
1190-
}
1191-
1192-
/**
1193-
* Don't trust third party responses, try and handle a variety of responses gracefully.
1194-
*/
1195-
async function _extractJsonFromResponse(
1196-
response: Response,
1197-
reportError: (...data: Array<any>) => void,
1198-
fileType: 'application/json' | null
1199-
): Promise<unknown> {
1200-
let arrayBuffer: ArrayBuffer | null = null;
1201-
try {
1202-
// await before returning so that we can catch JSON parse errors.
1203-
arrayBuffer = await response.arrayBuffer();
1204-
return await _extractJsonFromArrayBuffer(arrayBuffer);
1205-
} catch (error) {
1206-
// Change the error message depending on the circumstance:
1207-
let message;
1208-
if (error && typeof error === 'object' && error.name === 'AbortError') {
1209-
message = 'The network request to load the profile was aborted.';
1210-
} else if (fileType === 'application/json') {
1211-
message = 'The profile’s JSON could not be decoded.';
1212-
} else if (fileType === null && arrayBuffer !== null) {
1213-
// If the content type is not specified, use a raw array buffer
1214-
// to fallback to other supported profile formats.
1215-
return arrayBuffer;
1216-
} else {
1217-
message = oneLine`
1218-
The profile could not be downloaded and decoded. This does not look like a supported file
1219-
type.
1220-
`;
1221-
}
1222-
1223-
// Provide helpful debugging information to the console.
1224-
reportError(message);
1225-
reportError('JSON parsing error:', error);
1226-
reportError('Fetch response:', response);
1227-
1228-
throw new Error(
1229-
`${message} The full error information has been printed out to the DevTool’s console.`
1230-
);
1231-
}
1232-
}
1233-
1234-
export function getProfileUrlForHash(hash: string): string {
1235-
// See https://cloud.google.com/storage/docs/access-public-data
1236-
// The URL is https://storage.googleapis.com/<BUCKET>/<FILEPATH>.
1237-
// https://<BUCKET>.storage.googleapis.com/<FILEPATH> seems to also work but
1238-
// is not documented nowadays.
1239-
1240-
// By convention, "profile-store" is the name of our bucket, and the file path
1241-
// is the hash we receive in the URL.
1242-
return `https://storage.googleapis.com/${GOOGLE_STORAGE_BUCKET}/${hash}`;
1243-
}
1244-
1245990
export function retrieveProfileFromStore(
1246991
hash: string,
1247992
initialLoad: boolean = false
@@ -1262,7 +1007,7 @@ export function retrieveProfileOrZipFromUrl(
12621007
dispatch(waitingForProfileFromUrl(profileUrl));
12631008

12641009
try {
1265-
const response: ProfileOrZip = await _fetchProfile({
1010+
const response: ProfileOrZip = await fetchProfile({
12661011
url: profileUrl,
12671012
onTemporaryError: (e: TemporaryError) => {
12681013
dispatch(temporaryError(e));
@@ -1293,7 +1038,7 @@ export function retrieveProfileOrZipFromUrl(
12931038
default:
12941039
throw assertExhaustiveCheck(
12951040
response as never,
1296-
'Expected to receive an archive or profile from _fetchProfile.'
1041+
'Expected to receive an archive or profile from fetchProfile.'
12971042
);
12981043
}
12991044
} catch (error) {
@@ -1349,7 +1094,7 @@ export function retrieveProfileFromFile(
13491094
dispatch(waitingForProfileFromFile());
13501095

13511096
try {
1352-
if (_deduceContentType(file.name, file.type) === 'application/zip') {
1097+
if (deduceContentType(file.name, file.type) === 'application/zip') {
13531098
// Open a zip file in the zip file viewer
13541099
const buffer = await fileReader(file).asArrayBuffer();
13551100
const { default: JSZip } = await import('jszip');
@@ -1446,14 +1191,14 @@ export function retrieveProfilesToCompare(
14461191

14471192
const profileUrl = getProfileFetchUrl(url);
14481193

1449-
const response: ProfileOrZip = await _fetchProfile({
1194+
const response: ProfileOrZip = await fetchProfile({
14501195
url: profileUrl,
14511196
onTemporaryError: (e: TemporaryError) => {
14521197
dispatch(temporaryError(e));
14531198
},
14541199
});
14551200
if (response.responseType !== 'PROFILE') {
1456-
throw new Error('Expected to receive a profile from _fetchProfile');
1201+
throw new Error('Expected to receive a profile from fetchProfile');
14571202
}
14581203

14591204
const upgradeInfo: ProfileUpgradeInfo = {};

src/test/components/Root-history.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
import { Root } from '../../components/app/Root';
1212
import { autoMockCanvasContext } from '../fixtures/mocks/canvas-context';
1313
import { fireFullClick } from '../fixtures/utils';
14-
import { getProfileUrlForHash } from '../../actions/receive-profile';
14+
import { getProfileUrlForHash } from '../../utils/profile-fetch';
1515
import { blankStore } from '../fixtures/stores';
1616
import { getProfileFromTextSamples } from '../fixtures/profiles/processed-profile';
1717
import {

0 commit comments

Comments
 (0)