| // Copyright 2017 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import * as Common from '../common/common.js'; |
| import * as Platform from '../platform/platform.js'; |
| |
| import type {FrameAssociated} from './FrameAssociated.js'; |
| import {PageResourceLoader, type PageResourceLoadInitiator, type ResourceLoader} from './PageResourceLoader.js'; |
| import {type DebugId, parseSourceMap, SourceMap, type SourceMapV3} from './SourceMap.js'; |
| import {SourceMapCache} from './SourceMapCache.js'; |
| import {type Target, Type} from './Target.js'; |
| |
| export type SourceMapFactory<T> = |
| (compiledURL: Platform.DevToolsPath.UrlString, sourceMappingURL: Platform.DevToolsPath.UrlString, |
| payload: SourceMapV3, client: T) => SourceMap; |
| |
| export class SourceMapManager<T extends FrameAssociated> extends Common.ObjectWrapper.ObjectWrapper<EventTypes<T>> { |
| readonly #target: Target; |
| readonly #factory: SourceMapFactory<T>; |
| #isEnabled = true; |
| readonly #clientData = new Map<T, ClientData>(); |
| readonly #sourceMaps = new Map<SourceMap, T>(); |
| #attachingClient: T|null = null; |
| |
| constructor(target: Target, factory?: SourceMapFactory<T>) { |
| super(); |
| |
| this.#target = target; |
| this.#factory = |
| factory ?? ((compiledURL, sourceMappingURL, payload) => new SourceMap(compiledURL, sourceMappingURL, payload)); |
| } |
| |
| setEnabled(isEnabled: boolean): void { |
| if (isEnabled === this.#isEnabled) { |
| return; |
| } |
| |
| // We need this copy, because `this.#clientData` is getting modified |
| // in the loop body and trying to iterate over it at the same time |
| // leads to an infinite loop. |
| const clientData = [...this.#clientData.entries()]; |
| for (const [client] of clientData) { |
| this.detachSourceMap(client); |
| } |
| this.#isEnabled = isEnabled; |
| for (const [client, {relativeSourceURL, relativeSourceMapURL}] of clientData) { |
| this.attachSourceMap(client, relativeSourceURL, relativeSourceMapURL); |
| } |
| } |
| |
| private static getBaseUrl(target: Target|null): Platform.DevToolsPath.UrlString { |
| while (target && target.type() !== Type.FRAME) { |
| target = target.parentTarget(); |
| } |
| return target?.inspectedURL() ?? Platform.DevToolsPath.EmptyUrlString; |
| } |
| |
| static resolveRelativeSourceURL(target: Target|null, url: Platform.DevToolsPath.UrlString): |
| Platform.DevToolsPath.UrlString { |
| url = Common.ParsedURL.ParsedURL.completeURL(SourceMapManager.getBaseUrl(target), url) ?? url; |
| return url; |
| } |
| |
| sourceMapForClient(client: T): SourceMap|undefined { |
| return this.#clientData.get(client)?.sourceMap; |
| } |
| |
| // This method actively awaits the source map, if still loading. |
| sourceMapForClientPromise(client: T): Promise<SourceMap|undefined> { |
| const clientData = this.#clientData.get(client); |
| if (!clientData) { |
| return Promise.resolve(undefined); |
| } |
| |
| return clientData.sourceMapPromise; |
| } |
| |
| clientForSourceMap(sourceMap: SourceMap): T|undefined { |
| return this.#sourceMaps.get(sourceMap); |
| } |
| |
| // TODO(bmeurer): We are lying about the type of |relativeSourceURL| here. |
| attachSourceMap( |
| client: T, relativeSourceURL: Platform.DevToolsPath.UrlString, relativeSourceMapURL: string|undefined): void { |
| if (this.#clientData.has(client)) { |
| throw new Error('SourceMap is already attached or being attached to client'); |
| } |
| if (!relativeSourceMapURL) { |
| return; |
| } |
| |
| let clientData: ClientData|null = { |
| relativeSourceURL, |
| relativeSourceMapURL, |
| sourceMap: undefined, |
| sourceMapPromise: Promise.resolve(undefined), |
| }; |
| if (this.#isEnabled) { |
| // The `// #sourceURL=foo` can be a random string, but is generally an absolute path. |
| // Complete it to inspected page url for relative links. |
| const sourceURL = SourceMapManager.resolveRelativeSourceURL(this.#target, relativeSourceURL); |
| const sourceMapURL = Common.ParsedURL.ParsedURL.completeURL(sourceURL, relativeSourceMapURL); |
| if (sourceMapURL) { |
| if (this.#attachingClient) { |
| // This should not happen |
| console.error('Attaching source map may cancel previously attaching source map'); |
| } |
| this.#attachingClient = client; |
| this.dispatchEventToListeners(Events.SourceMapWillAttach, {client}); |
| |
| if (this.#attachingClient === client) { |
| this.#attachingClient = null; |
| const initiator = client.createPageResourceLoadInitiator(); |
| // TODO(crbug.com/458180550): Pass PageResourceLoader via constructor. |
| // The reason we grab it here lazily from the context is that otherwise every |
| // unit test using `createTarget` would need to set up a `PageResourceLoader`, as |
| // CSSModel and DebuggerModel are autostarted by default, and they create a |
| // SourceMapManager in their respective constructors. |
| const resourceLoader = this.#target.targetManager().context.get(PageResourceLoader); |
| clientData.sourceMapPromise = |
| loadSourceMap(resourceLoader, sourceMapURL, client.debugId(), initiator) |
| .then( |
| payload => { |
| const sourceMap = this.#factory(sourceURL, sourceMapURL, payload, client); |
| if (this.#clientData.get(client) === clientData) { |
| clientData.sourceMap = sourceMap; |
| this.#sourceMaps.set(sourceMap, client); |
| this.dispatchEventToListeners(Events.SourceMapAttached, {client, sourceMap}); |
| } |
| return sourceMap; |
| }, |
| () => { |
| if (this.#clientData.get(client) === clientData) { |
| this.dispatchEventToListeners(Events.SourceMapFailedToAttach, {client}); |
| } |
| return undefined; |
| }); |
| } else { |
| // Assume cancelAttachSourceMap was called. |
| if (this.#attachingClient) { |
| // This should not happen |
| console.error('Cancelling source map attach because another source map is attaching'); |
| } |
| clientData = null; |
| this.dispatchEventToListeners(Events.SourceMapFailedToAttach, {client}); |
| } |
| } |
| } |
| if (clientData) { |
| this.#clientData.set(client, clientData); |
| } |
| } |
| |
| cancelAttachSourceMap(client: T): void { |
| if (client === this.#attachingClient) { |
| this.#attachingClient = null; |
| // This should not happen. |
| } else if (this.#attachingClient) { |
| console.error('cancel attach source map requested but a different source map was being attached'); |
| } else { |
| console.error('cancel attach source map requested but no source map was being attached'); |
| } |
| } |
| |
| detachSourceMap(client: T): void { |
| const clientData = this.#clientData.get(client); |
| if (!clientData) { |
| return; |
| } |
| this.#clientData.delete(client); |
| if (!this.#isEnabled) { |
| return; |
| } |
| const {sourceMap} = clientData; |
| if (sourceMap) { |
| this.#sourceMaps.delete(sourceMap); |
| this.dispatchEventToListeners(Events.SourceMapDetached, {client, sourceMap}); |
| } else { |
| this.dispatchEventToListeners(Events.SourceMapFailedToAttach, {client}); |
| } |
| } |
| |
| waitForSourceMapsProcessedForTest(): Promise<unknown> { |
| return Promise.all(this.#sourceMaps.keys().map(sourceMap => sourceMap.waitForScopeInfo())); |
| } |
| } |
| |
| async function loadSourceMap( |
| resourceLoader: ResourceLoader, url: Platform.DevToolsPath.UrlString, debugId: DebugId|null, |
| initiator: PageResourceLoadInitiator): Promise<SourceMapV3> { |
| try { |
| if (debugId) { |
| const cachedSourceMap = await SourceMapCache.instance().get(debugId); |
| if (cachedSourceMap) { |
| return cachedSourceMap; |
| } |
| } |
| |
| const {content} = await resourceLoader.loadResource(url, initiator); |
| const sourceMap = parseSourceMap(content); |
| if ('debugId' in sourceMap && sourceMap.debugId) { |
| // In case something goes wrong with updating the cache, we still want to use the source map. |
| await SourceMapCache.instance().set(sourceMap.debugId as DebugId, sourceMap).catch(); |
| } |
| return sourceMap; |
| } catch (cause) { |
| throw new Error(`Could not load content for ${url}: ${cause.message}`, {cause}); |
| } |
| } |
| |
| export async function tryLoadSourceMap( |
| resourceLoader: ResourceLoader, url: Platform.DevToolsPath.UrlString, |
| initiator: PageResourceLoadInitiator): Promise<SourceMapV3|null> { |
| try { |
| return await loadSourceMap(resourceLoader, url, null, initiator); |
| } catch (cause) { |
| console.error(cause); |
| return null; |
| } |
| } |
| |
| interface ClientData { |
| relativeSourceURL: Platform.DevToolsPath.UrlString; |
| // Stores the raw sourceMappingURL as provided by V8. These are not guaranteed to |
| // be valid URLs and will be checked and resolved once `attachSourceMap` is called. |
| relativeSourceMapURL: string; |
| sourceMap: SourceMap|undefined; |
| sourceMapPromise: Promise<SourceMap|undefined>; |
| } |
| |
| export enum Events { |
| /* eslint-disable @typescript-eslint/naming-convention -- Used by web_tests. */ |
| SourceMapWillAttach = 'SourceMapWillAttach', |
| SourceMapFailedToAttach = 'SourceMapFailedToAttach', |
| SourceMapAttached = 'SourceMapAttached', |
| SourceMapDetached = 'SourceMapDetached', |
| /* eslint-enable @typescript-eslint/naming-convention */ |
| } |
| |
| export interface EventTypes<T extends FrameAssociated> { |
| [Events.SourceMapWillAttach]: {client: T}; |
| [Events.SourceMapFailedToAttach]: {client: T}; |
| [Events.SourceMapAttached]: {client: T, sourceMap: SourceMap}; |
| [Events.SourceMapDetached]: {client: T, sourceMap: SourceMap}; |
| } |