chrome-devtools-frontend
Version:
Chrome DevTools UI
469 lines (428 loc) • 18 kB
text/typescript
// Copyright 2020 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import type * as Protocol from '../../generated/protocol.js';
import * as Common from '../common/common.js';
import * as Host from '../host/host.js';
import * as i18n from '../i18n/i18n.js';
import type * as Platform from '../platform/platform.js';
import * as Root from '../root/root.js';
import {IOModel} from './IOModel.js';
import {MultitargetNetworkManager, NetworkManager} from './NetworkManager.js';
import {
Events as ResourceTreeModelEvents,
PrimaryPageChangeType,
type ResourceTreeFrame,
ResourceTreeModel,
} from './ResourceTreeModel.js';
import type {Target} from './Target.js';
import {TargetManager} from './TargetManager.js';
const UIStrings = {
/**
* @description Error message for canceled source map loads
*/
loadCanceledDueToReloadOf: 'Load canceled due to reload of inspected page',
} as const;
const str_ = i18n.i18n.registerUIStrings('core/sdk/PageResourceLoader.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
export interface ExtensionInitiator {
target: null;
frameId: null;
initiatorUrl: Platform.DevToolsPath.UrlString;
extensionId: string;
}
export type PageResourceLoadInitiator = {
target: null,
frameId: Protocol.Page.FrameId,
initiatorUrl: Platform.DevToolsPath.UrlString|null,
}|{
target: Target,
frameId: Protocol.Page.FrameId | null,
initiatorUrl: Platform.DevToolsPath.UrlString | null,
}|ExtensionInitiator;
function isExtensionInitiator(initiator: PageResourceLoadInitiator): initiator is ExtensionInitiator {
return 'extensionId' in initiator;
}
export interface PageResource {
success: boolean|null;
errorMessage?: string;
initiator: PageResourceLoadInitiator;
url: Platform.DevToolsPath.UrlString;
size: number|null;
duration: number|null;
}
/** Used for revealing a resource. **/
export class ResourceKey {
readonly key: string;
constructor(key: string) {
this.key = key;
}
}
export type UserAgentProvider = Pick<MultitargetNetworkManager, 'currentUserAgent'>;
/**
* The PageResourceLoader has two responsibilities: loading resources and tracking statistics scoped to targets
* for the DeveloperResourcesPanel. Many places only require the former, so we expose that functionality via small
* sub-interface. This makes it easier to test classes that require resource loading.
*/
export type ResourceLoader = Pick<PageResourceLoader, 'loadResource'>;
interface LoadQueueEntry {
resolve: () => void;
reject: (arg0: Error) => void;
}
/**
* The page resource loader is a bottleneck for all DevTools-initiated resource loads. For each such load, it keeps a
* `PageResource` object around that holds meta information. This can be as the basis for reporting to the user which
* resources were loaded, and whether there was a load error.
*/
export class PageResourceLoader extends Common.ObjectWrapper.ObjectWrapper<EventTypes> implements ResourceLoader {
readonly #targetManager: TargetManager;
readonly #settings: Common.Settings.Settings;
readonly #userAgentProvider: UserAgentProvider;
#currentlyLoading = 0;
#currentlyLoadingPerTarget = new Map<Protocol.Target.TargetID|'main', number>();
readonly #maxConcurrentLoads: number;
#pageResources = new Map<string, PageResource>();
#queuedLoads: LoadQueueEntry[] = [];
readonly #loadOverride: ((arg0: string) => Promise<{
success: boolean,
content: string|Uint8Array<ArrayBuffer>,
errorDescription: Host.ResourceLoader.LoadErrorDescription,
}>)|null;
constructor(
targetManager: TargetManager, settings: Common.Settings.Settings, userAgentProvider: UserAgentProvider,
loadOverride: ((arg0: string) => Promise<{
success: boolean,
content: string|Uint8Array<ArrayBuffer>,
errorDescription: Host.ResourceLoader.LoadErrorDescription,
}>)|null,
maxConcurrentLoads = 500) {
super();
this.#targetManager = targetManager;
this.#settings = settings;
this.#userAgentProvider = userAgentProvider;
this.#maxConcurrentLoads = maxConcurrentLoads;
this.#targetManager.addModelListener(
ResourceTreeModel, ResourceTreeModelEvents.PrimaryPageChanged, this.onPrimaryPageChanged, this);
this.#loadOverride = loadOverride;
}
static instance({forceNew, targetManager, settings, userAgentProvider, loadOverride, maxConcurrentLoads}: {
forceNew: boolean,
loadOverride: (null|((arg0: string) => Promise<{
success: boolean,
content: string|Uint8Array<ArrayBuffer>,
errorDescription: Host.ResourceLoader.LoadErrorDescription,
}>)),
targetManager?: TargetManager,
settings?: Common.Settings.Settings,
userAgentProvider?: UserAgentProvider,
maxConcurrentLoads?: number,
} = {
forceNew: false,
loadOverride: null,
}): PageResourceLoader {
if (forceNew) {
Root.DevToolsContext.globalInstance().set(
PageResourceLoader,
new PageResourceLoader(
targetManager ?? TargetManager.instance(), settings ?? Common.Settings.Settings.instance(),
userAgentProvider ?? MultitargetNetworkManager.instance(), loadOverride, maxConcurrentLoads));
}
return Root.DevToolsContext.globalInstance().get(PageResourceLoader);
}
static removeInstance(): void {
Root.DevToolsContext.globalInstance().delete(PageResourceLoader);
}
onPrimaryPageChanged(
event: Common.EventTarget.EventTargetEvent<{frame: ResourceTreeFrame, type: PrimaryPageChangeType}>): void {
const {frame: mainFrame, type} = event.data;
if (!mainFrame.isOutermostFrame()) {
return;
}
for (const {reject} of this.#queuedLoads) {
reject(new Error(i18nString(UIStrings.loadCanceledDueToReloadOf)));
}
this.#queuedLoads = [];
const mainFrameTarget = mainFrame.resourceTreeModel().target();
const keptResources = new Map<string, PageResource>();
// If the navigation is a prerender-activation, the pageResources for the destination page have
// already been preloaded. In such cases, we therefore don't just discard all pageResources, but
// instead make sure to keep the pageResources for the prerendered target.
for (const [key, pageResource] of this.#pageResources.entries()) {
if ((type === PrimaryPageChangeType.ACTIVATION) && mainFrameTarget === pageResource.initiator.target) {
keptResources.set(key, pageResource);
}
}
this.#pageResources = keptResources;
this.dispatchEventToListeners(Events.UPDATE);
}
getResourcesLoaded(): Map<string, PageResource> {
return this.#pageResources;
}
getScopedResourcesLoaded(): Map<string, PageResource> {
return new Map([...this.#pageResources].filter(
([_, pageResource]) => this.#targetManager.isInScope(pageResource.initiator.target) ||
isExtensionInitiator(pageResource.initiator)));
}
/**
* Loading is the number of currently loading and queued items. Resources is the total number of resources,
* including loading and queued resources, but not including resources that are still loading but scheduled
* for cancelation.;
*/
getNumberOfResources(): {
loading: number,
queued: number,
resources: number,
} {
return {loading: this.#currentlyLoading, queued: this.#queuedLoads.length, resources: this.#pageResources.size};
}
getScopedNumberOfResources(): {
loading: number,
resources: number,
} {
let loadingCount = 0;
for (const [targetId, count] of this.#currentlyLoadingPerTarget) {
const target = this.#targetManager.targetById(targetId);
if (this.#targetManager.isInScope(target)) {
loadingCount += count;
}
}
return {loading: loadingCount, resources: this.getScopedResourcesLoaded().size};
}
private async acquireLoadSlot(target: Target|null): Promise<void> {
this.#currentlyLoading++;
if (target) {
const currentCount = this.#currentlyLoadingPerTarget.get(target.id()) || 0;
this.#currentlyLoadingPerTarget.set(target.id(), currentCount + 1);
}
if (this.#currentlyLoading > this.#maxConcurrentLoads) {
const {
promise: waitForCapacity,
resolve,
reject,
} = Promise.withResolvers<void>();
this.#queuedLoads.push({resolve, reject});
await waitForCapacity;
}
}
private releaseLoadSlot(target: Target|null): void {
this.#currentlyLoading--;
if (target) {
const currentCount = this.#currentlyLoadingPerTarget.get(target.id());
if (currentCount) {
this.#currentlyLoadingPerTarget.set(target.id(), currentCount - 1);
}
}
const entry = this.#queuedLoads.shift();
if (entry) {
entry.resolve();
}
}
static makeExtensionKey(url: Platform.DevToolsPath.UrlString, initiator: PageResourceLoadInitiator): string {
if (isExtensionInitiator(initiator) && initiator.extensionId) {
return `${url}-${initiator.extensionId}`;
}
throw new Error('Invalid initiator');
}
static makeKey(url: Platform.DevToolsPath.UrlString, initiator: PageResourceLoadInitiator): string {
if (initiator.frameId) {
return `${url}-${initiator.frameId}`;
}
if (initiator.target) {
return `${url}-${initiator.target.id()}`;
}
throw new Error('Invalid initiator');
}
resourceLoadedThroughExtension(pageResource: PageResource): void {
const key = PageResourceLoader.makeExtensionKey(pageResource.url, pageResource.initiator);
this.#pageResources.set(key, pageResource);
this.dispatchEventToListeners(Events.UPDATE);
}
loadResource(url: Platform.DevToolsPath.UrlString, initiator: PageResourceLoadInitiator, isBinary: true): Promise<{
content: Uint8Array<ArrayBuffer>,
}>;
loadResource(url: Platform.DevToolsPath.UrlString, initiator: PageResourceLoadInitiator, isBinary?: false): Promise<{
content: string,
}>;
async loadResource(url: Platform.DevToolsPath.UrlString, initiator: PageResourceLoadInitiator, isBinary = false):
Promise<{
content: string | Uint8Array<ArrayBuffer>,
}> {
if (isExtensionInitiator(initiator)) {
throw new Error('Invalid initiator');
}
const key = PageResourceLoader.makeKey(url, initiator);
const pageResource:
PageResource = {success: null, size: null, duration: null, errorMessage: undefined, url, initiator};
this.#pageResources.set(key, pageResource);
this.dispatchEventToListeners(Events.UPDATE);
const startTime = performance.now();
try {
await this.acquireLoadSlot(initiator.target);
const resultPromise = this.dispatchLoad(url, initiator, isBinary);
const result = await resultPromise;
pageResource.errorMessage = result.errorDescription.message;
pageResource.success = result.success;
if (result.success) {
pageResource.size = result.content.length;
return {content: result.content};
}
throw new Error(result.errorDescription.message);
} catch (e) {
if (pageResource.errorMessage === undefined) {
pageResource.errorMessage = e.message;
}
if (pageResource.success === null) {
pageResource.success = false;
}
throw e;
} finally {
pageResource.duration = performance.now() - startTime;
this.releaseLoadSlot(initiator.target);
this.dispatchEventToListeners(Events.UPDATE);
}
}
private async dispatchLoad(
url: Platform.DevToolsPath.UrlString, initiator: PageResourceLoadInitiator, isBinary: boolean): Promise<{
success: boolean,
content: string|Uint8Array<ArrayBuffer>,
errorDescription: Host.ResourceLoader.LoadErrorDescription,
}> {
if (isExtensionInitiator(initiator)) {
throw new Error('Invalid initiator');
}
const failureReason: string|null = null;
if (this.#loadOverride) {
return await this.#loadOverride(url);
}
const parsedURL = new Common.ParsedURL.ParsedURL(url);
const eligibleForLoadFromTarget = this.getLoadThroughTargetSetting().get() && parsedURL &&
parsedURL.scheme !== 'file' && parsedURL.scheme !== 'data' && parsedURL.scheme !== 'devtools' &&
initiator.target;
Host.userMetrics.developerResourceScheme(this.getDeveloperResourceScheme(parsedURL));
if (eligibleForLoadFromTarget) {
try {
Host.userMetrics.developerResourceLoaded(Host.UserMetrics.DeveloperResourceLoaded.LOAD_THROUGH_PAGE_VIA_TARGET);
const result = await this.loadFromTarget(initiator.target, initiator.frameId, url, isBinary);
return result;
} catch (e) {
if (e instanceof Error) {
Host.userMetrics.developerResourceLoaded(Host.UserMetrics.DeveloperResourceLoaded.LOAD_THROUGH_PAGE_FAILURE);
if (e.message.includes('CSP violation')) {
return {
success: false,
content: '',
errorDescription:
{statusCode: 0, netError: undefined, netErrorName: undefined, message: e.message, urlValid: undefined}
};
}
}
}
Host.userMetrics.developerResourceLoaded(Host.UserMetrics.DeveloperResourceLoaded.LOAD_THROUGH_PAGE_FALLBACK);
} else {
const code = this.getLoadThroughTargetSetting().get() ?
Host.UserMetrics.DeveloperResourceLoaded.FALLBACK_PER_PROTOCOL :
Host.UserMetrics.DeveloperResourceLoaded.FALLBACK_PER_OVERRIDE;
Host.userMetrics.developerResourceLoaded(code);
}
const result = await this.loadFromHostBindings(url);
if (eligibleForLoadFromTarget && !result.success) {
Host.userMetrics.developerResourceLoaded(Host.UserMetrics.DeveloperResourceLoaded.FALLBACK_FAILURE);
}
if (failureReason) {
// In case we have a success, add a note about why the load through the target failed.
result.errorDescription.message =
`Fetch through target failed: ${failureReason}; Fallback: ${result.errorDescription.message}`;
}
return result;
}
private getDeveloperResourceScheme(parsedURL: Common.ParsedURL.ParsedURL|null):
Host.UserMetrics.DeveloperResourceScheme {
if (!parsedURL || parsedURL.scheme === '') {
return Host.UserMetrics.DeveloperResourceScheme.UKNOWN;
}
const isLocalhost = parsedURL.host === 'localhost' || parsedURL.host.endsWith('.localhost');
switch (parsedURL.scheme) {
case 'file':
return Host.UserMetrics.DeveloperResourceScheme.FILE;
case 'data':
return Host.UserMetrics.DeveloperResourceScheme.DATA;
case 'blob':
return Host.UserMetrics.DeveloperResourceScheme.BLOB;
case 'http':
return isLocalhost ? Host.UserMetrics.DeveloperResourceScheme.HTTP_LOCALHOST :
Host.UserMetrics.DeveloperResourceScheme.HTTP;
case 'https':
return isLocalhost ? Host.UserMetrics.DeveloperResourceScheme.HTTPS_LOCALHOST :
Host.UserMetrics.DeveloperResourceScheme.HTTPS;
}
return Host.UserMetrics.DeveloperResourceScheme.OTHER;
}
private async loadFromTarget(
target: Target, frameId: Protocol.Page.FrameId|null, url: Platform.DevToolsPath.UrlString,
isBinary: boolean): Promise<{
success: boolean,
content: string|Uint8Array<ArrayBuffer>,
errorDescription: {
statusCode: number,
netError: number|undefined,
netErrorName: string|undefined,
message: string,
urlValid: undefined,
},
}> {
const networkManager = (target.model(NetworkManager) as NetworkManager);
const ioModel = (target.model(IOModel) as IOModel);
const disableCache = this.#settings.moduleSetting('cache-disabled').get();
const resource = await networkManager.loadNetworkResource(frameId, url, {disableCache, includeCredentials: true});
try {
const content = resource.stream ?
(isBinary ? await ioModel.readToBuffer(resource.stream) : await ioModel.readToString(resource.stream)) :
'';
return {
success: resource.success,
content,
errorDescription: {
statusCode: resource.httpStatusCode || 0,
netError: resource.netError,
netErrorName: resource.netErrorName,
message: Host.ResourceLoader.netErrorToMessage(
resource.netError, resource.httpStatusCode, resource.netErrorName) ||
'',
urlValid: undefined,
},
};
} finally {
if (resource.stream) {
void ioModel.close(resource.stream);
}
}
}
private async loadFromHostBindings(url: Platform.DevToolsPath.UrlString): Promise<{
success: boolean,
content: string,
errorDescription: Host.ResourceLoader.LoadErrorDescription,
}> {
const headers: Record<string, string> = {};
const currentUserAgent = this.#userAgentProvider.currentUserAgent();
if (currentUserAgent) {
headers['User-Agent'] = currentUserAgent;
}
if (this.#settings.moduleSetting('cache-disabled').get()) {
headers['Cache-Control'] = 'no-cache';
}
const allowRemoteFilePaths = this.#settings.moduleSetting('network.enable-remote-file-loading').get();
return await new Promise(
resolve => Host.ResourceLoader.load(url, headers, (success, _responseHeaders, content, errorDescription) => {
resolve({success, content, errorDescription});
}, allowRemoteFilePaths));
}
getLoadThroughTargetSetting(): Common.Settings.Setting<boolean> {
return this.#settings.createSetting('load-through-target', true);
}
}
export const enum Events {
UPDATE = 'Update',
}
export interface EventTypes {
[Events.UPDATE]: void;
}