UNPKG

chrome-devtools-frontend

Version:
436 lines (401 loc) • 16.4 kB
// 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 {FrameManager} from './FrameManager.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; } } let pageResourceLoader: PageResourceLoader|null = null; 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> { #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, errorDescription: Host.ResourceLoader.LoadErrorDescription, }>)|null; constructor( loadOverride: ((arg0: string) => Promise<{ success: boolean, content: string, errorDescription: Host.ResourceLoader.LoadErrorDescription, }>)|null, maxConcurrentLoads: number) { super(); this.#maxConcurrentLoads = maxConcurrentLoads; TargetManager.instance().addModelListener( ResourceTreeModel, ResourceTreeModelEvents.PrimaryPageChanged, this.onPrimaryPageChanged, this); this.#loadOverride = loadOverride; } static instance({forceNew, loadOverride, maxConcurrentLoads}: { forceNew: boolean, loadOverride: (null|((arg0: string) => Promise<{ success: boolean, content: string, errorDescription: Host.ResourceLoader.LoadErrorDescription, }>)), maxConcurrentLoads: number, } = { forceNew: false, loadOverride: null, maxConcurrentLoads: 500, }): PageResourceLoader { if (!pageResourceLoader || forceNew) { pageResourceLoader = new PageResourceLoader(loadOverride, maxConcurrentLoads); } return pageResourceLoader; } static removeInstance(): void { pageResourceLoader = null; } 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]) => TargetManager.instance().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, } { const targetManager = TargetManager.instance(); let loadingCount = 0; for (const [targetId, count] of this.#currentlyLoadingPerTarget) { const target = targetManager.targetById(targetId); if (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 = getLoadThroughTargetSetting().get() && parsedURL && parsedURL.scheme !== 'file' && parsedURL.scheme !== 'data' && parsedURL.scheme !== 'devtools'; Host.userMetrics.developerResourceScheme(this.getDeveloperResourceScheme(parsedURL)); if (eligibleForLoadFromTarget) { try { if (initiator.target) { Host.userMetrics.developerResourceLoaded( Host.UserMetrics.DeveloperResourceLoaded.LOAD_THROUGH_PAGE_VIA_TARGET); const result = await this.loadFromTarget(initiator.target, initiator.frameId, url, isBinary); return result; } const frame = FrameManager.instance().getFrame(initiator.frameId); if (frame) { Host.userMetrics.developerResourceLoaded( Host.UserMetrics.DeveloperResourceLoaded.LOAD_THROUGH_PAGE_VIA_FRAME); const result = await this.loadFromTarget(frame.resourceTreeModel().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 = getLoadThroughTargetSetting().get() ? Host.UserMetrics.DeveloperResourceLoaded.FALLBACK_PER_PROTOCOL : Host.UserMetrics.DeveloperResourceLoaded.FALLBACK_PER_OVERRIDE; Host.userMetrics.developerResourceLoaded(code); } const result = await MultitargetNetworkManager.instance().loadResource(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 = Common.Settings.Settings.instance().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); } } } } export function getLoadThroughTargetSetting(): Common.Settings.Setting<boolean> { return Common.Settings.Settings.instance().createSetting('load-through-target', true); } export const enum Events { UPDATE = 'Update', } export interface EventTypes { [Events.UPDATE]: void; }