UNPKG

chrome-devtools-frontend

Version:
535 lines (479 loc) • 21.9 kB
// Copyright 2021 The Chromium Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /* * Copyright (C) 2008 Apple Inc. All Rights Reserved. * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions * are met: * 1. Redistributions of source code must retain the above copyright * notice, this list of conditions and the following disclaimer. * 2. Redistributions in binary form must reproduce the above copyright * notice, this list of conditions and the following disclaimer in the * documentation and/or other materials provided with the distribution. * * THIS SOFTWARE IS PROVIDED BY APPLE INC. ``AS IS'' AND ANY * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ import * as Platform from '../../core/platform/platform.js'; import * as Protocol from '../../generated/protocol.js'; import * as TextUtils from '../../models/text_utils/text_utils.js'; import * as Common from '../common/common.js'; import * as i18n from '../i18n/i18n.js'; import { COND_BREAKPOINT_SOURCE_URL, type DebuggerModel, Events, Location, LOGPOINT_SOURCE_URL, } from './DebuggerModel.js'; import type {FrameAssociated} from './FrameAssociated.js'; import type {PageResourceLoadInitiator} from './PageResourceLoader.js'; import {ResourceTreeModel} from './ResourceTreeModel.js'; import type {ExecutionContext} from './RuntimeModel.js'; import type {DebugId, SourceMap} from './SourceMap.js'; import type {Target} from './Target.js'; const UIStrings = { /** * @description Error message for when a script can't be loaded which had been previously */ scriptRemovedOrDeleted: 'Script removed or deleted.', /** * @description Error message when failing to load a script source text */ unableToFetchScriptSource: 'Unable to fetch script source.', } as const; const str_ = i18n.i18n.registerUIStrings('core/sdk/Script.ts', UIStrings); const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); let scriptCacheInstance: { cache: Map<string, WeakRef<Promise<TextUtils.ContentData.ContentDataOrError>>>, registry: FinalizationRegistry<string>, }|null = null; export class Script implements TextUtils.ContentProvider.ContentProvider, FrameAssociated { debuggerModel: DebuggerModel; scriptId: Protocol.Runtime.ScriptId; /** * The URL of the script. When `hasSourceURL` is true, this value comes from a `//# sourceURL=` directive. Otherwise, * it's the original `src` URL from which the script was loaded. */ sourceURL: Platform.DevToolsPath.UrlString; lineOffset: number; columnOffset: number; endLine: number; endColumn: number; executionContextId: number; hash: string; readonly #isContentScript: boolean; readonly #isLiveEdit: boolean; sourceMapURL?: string; debugSymbols: Protocol.Debugger.DebugSymbols|null; hasSourceURL: boolean; contentLength: number; originStackTrace: Protocol.Runtime.StackTrace|null; readonly #codeOffset: number|null; readonly #language: string|null; #contentPromise: Promise<TextUtils.ContentData.ContentDataOrError>|null; readonly #embedderName: Platform.DevToolsPath.UrlString|null; readonly isModule: boolean|null; readonly buildId: string|null; constructor( debuggerModel: DebuggerModel, scriptId: Protocol.Runtime.ScriptId, sourceURL: Platform.DevToolsPath.UrlString, startLine: number, startColumn: number, endLine: number, endColumn: number, executionContextId: number, hash: string, isContentScript: boolean, isLiveEdit: boolean, sourceMapURL: string|undefined, hasSourceURL: boolean, length: number, isModule: boolean|null, originStackTrace: Protocol.Runtime.StackTrace|null, codeOffset: number|null, scriptLanguage: string|null, debugSymbols: Protocol.Debugger.DebugSymbols|null, embedderName: Platform.DevToolsPath.UrlString|null, buildId: string|null) { this.debuggerModel = debuggerModel; this.scriptId = scriptId; this.sourceURL = sourceURL; this.lineOffset = startLine; this.columnOffset = startColumn; this.endLine = endLine; this.endColumn = endColumn; this.isModule = isModule; this.buildId = buildId; this.executionContextId = executionContextId; this.hash = hash; this.#isContentScript = isContentScript; this.#isLiveEdit = isLiveEdit; this.sourceMapURL = sourceMapURL; this.debugSymbols = debugSymbols; this.hasSourceURL = hasSourceURL; this.contentLength = length; this.originStackTrace = originStackTrace; this.#codeOffset = codeOffset; this.#language = scriptLanguage; this.#contentPromise = null; this.#embedderName = embedderName; } embedderName(): Platform.DevToolsPath.UrlString|null { return this.#embedderName; } target(): Target { return this.debuggerModel.target(); } private static trimSourceURLComment(source: string): string { let sourceURLIndex = source.lastIndexOf('//# sourceURL='); if (sourceURLIndex === -1) { sourceURLIndex = source.lastIndexOf('//@ sourceURL='); if (sourceURLIndex === -1) { return source; } } const sourceURLLineIndex = source.lastIndexOf('\n', sourceURLIndex); if (sourceURLLineIndex === -1) { return source; } const sourceURLLine = source.substr(sourceURLLineIndex + 1); if (!sourceURLLine.match(sourceURLRegex)) { return source; } return source.substr(0, sourceURLLineIndex); } isContentScript(): boolean { return this.#isContentScript; } codeOffset(): number|null { return this.#codeOffset; } isJavaScript(): boolean { return this.#language === Protocol.Debugger.ScriptLanguage.JavaScript; } isWasm(): boolean { return this.#language === Protocol.Debugger.ScriptLanguage.WebAssembly; } scriptLanguage(): string|null { return this.#language; } executionContext(): ExecutionContext|null { return this.debuggerModel.runtimeModel().executionContext(this.executionContextId); } isLiveEdit(): boolean { return this.#isLiveEdit; } contentURL(): Platform.DevToolsPath.UrlString { return this.sourceURL; } contentType(): Common.ResourceType.ResourceType { return Common.ResourceType.resourceTypes.Script; } private async loadTextContent(): Promise<TextUtils.ContentData.ContentData> { const result = await this.debuggerModel.target().debuggerAgent().invoke_getScriptSource({scriptId: this.scriptId}); if (result.getError()) { throw new Error(result.getError()); } const {scriptSource, bytecode} = result; if (bytecode) { return new TextUtils.ContentData.ContentData(bytecode, /* isBase64 */ true, 'application/wasm'); } let content: string = scriptSource || ''; if (this.hasSourceURL && Common.ParsedURL.schemeIs(this.sourceURL, 'snippet:')) { // TODO(crbug.com/1330846): Find a better way to establish the snippet automapping binding then adding // a sourceURL comment before evaluation and removing it here. content = Script.trimSourceURLComment(content); } return new TextUtils.ContentData.ContentData(content, /* isBase64 */ false, 'text/javascript'); } private async loadWasmContent(): Promise<TextUtils.ContentData.ContentDataOrError> { if (!this.isWasm()) { throw new Error('Not a wasm script'); } const result = await this.debuggerModel.target().debuggerAgent().invoke_disassembleWasmModule({scriptId: this.scriptId}); if (result.getError()) { // Fall through to text content loading if v8-based disassembly fails. This is to ensure backwards compatibility with // older v8 versions. const contentData = await this.loadTextContent(); return await disassembleWasm(contentData.base64); } const {streamId, functionBodyOffsets, chunk: {lines, bytecodeOffsets}} = result; const lineChunks = []; const bytecodeOffsetChunks = []; let totalLength = lines.reduce<number>((sum, line) => sum + line.length + 1, 0); const truncationMessage = '<truncated>'; // This is a magic number used in code mirror which, when exceeded, sends it into an infinite loop. const cmSizeLimit = 1000000000 - truncationMessage.length; if (streamId) { while (true) { const result = await this.debuggerModel.target().debuggerAgent().invoke_nextWasmDisassemblyChunk({streamId}); if (result.getError()) { throw new Error(result.getError()); } const {chunk: {lines: linesChunk, bytecodeOffsets: bytecodeOffsetsChunk}} = result; totalLength += linesChunk.reduce<number>((sum, line) => sum + line.length + 1, 0); if (linesChunk.length === 0) { break; } if (totalLength >= cmSizeLimit) { lineChunks.push([truncationMessage]); bytecodeOffsetChunks.push([0]); break; } lineChunks.push(linesChunk); bytecodeOffsetChunks.push(bytecodeOffsetsChunk); } } const functionBodyRanges: Array<{start: number, end: number}> = []; // functionBodyOffsets contains a sequence of pairs of start and end offsets for (let i = 0; i < functionBodyOffsets.length; i += 2) { functionBodyRanges.push({start: functionBodyOffsets[i], end: functionBodyOffsets[i + 1]}); } return new TextUtils.WasmDisassembly.WasmDisassembly( lines.concat(...lineChunks), bytecodeOffsets.concat(...bytecodeOffsetChunks), functionBodyRanges); } requestContentData(): Promise<TextUtils.ContentData.ContentDataOrError> { if (!this.#contentPromise) { const fileSizeToCache = 65535; // We won't bother cacheing files under 64K if (this.hash && !this.#isLiveEdit && this.contentLength > fileSizeToCache) { // For large files that aren't live edits and have a hash, we keep a content-addressed cache // so we don't need to load multiple copies or disassemble wasm modules multiple times. if (!scriptCacheInstance) { // Initialize script cache singleton. Add a finalizer for removing keys from the map. scriptCacheInstance = { cache: new Map(), registry: new FinalizationRegistry(hashCode => scriptCacheInstance?.cache.delete(hashCode)), }; } // This key should be sufficient to identify scripts that are known to have the same content. const fullHash = [ this.#language, this.contentLength, this.lineOffset, this.columnOffset, this.endLine, this.endColumn, this.#codeOffset, this.hash, ].join(':'); const cachedContentPromise = scriptCacheInstance.cache.get(fullHash)?.deref(); if (cachedContentPromise) { this.#contentPromise = cachedContentPromise; } else { this.#contentPromise = this.#requestContent(); scriptCacheInstance.cache.set(fullHash, new WeakRef(this.#contentPromise)); scriptCacheInstance.registry.register(this.#contentPromise, fullHash); } } else { this.#contentPromise = this.#requestContent(); } } return this.#contentPromise; } async #requestContent(): Promise<TextUtils.ContentData.ContentDataOrError> { if (!this.scriptId) { return {error: i18nString(UIStrings.scriptRemovedOrDeleted)}; } try { return this.isWasm() ? await this.loadWasmContent() : await this.loadTextContent(); } catch { // TODO(bmeurer): Propagate errors as exceptions / rejections. return {error: i18nString(UIStrings.unableToFetchScriptSource)}; } } async getWasmBytecode(): Promise<ArrayBuffer> { const base64 = await this.debuggerModel.target().debuggerAgent().invoke_getWasmBytecode({scriptId: this.scriptId}); const response = await fetch(`data:application/wasm;base64,${base64.bytecode}`); return await response.arrayBuffer(); } originalContentProvider(): TextUtils.ContentProvider.ContentProvider { return new TextUtils.StaticContentProvider.StaticContentProvider( this.contentURL(), this.contentType(), () => this.requestContentData()); } async searchInContent(query: string, caseSensitive: boolean, isRegex: boolean): Promise<TextUtils.ContentProvider.SearchMatch[]> { if (!this.scriptId) { return []; } const matches = await this.debuggerModel.target().debuggerAgent().invoke_searchInContent( {scriptId: this.scriptId, query, caseSensitive, isRegex}); return TextUtils.TextUtils.performSearchInSearchMatches(matches.result || [], query, caseSensitive, isRegex); } private appendSourceURLCommentIfNeeded(source: string): string { if (!this.hasSourceURL) { return source; } return source + '\n //# sourceURL=' + this.sourceURL; } async editSource(newSource: string): Promise<{ changed: boolean, status: Protocol.Debugger.SetScriptSourceResponseStatus, exceptionDetails?: Protocol.Runtime.ExceptionDetails, }> { newSource = Script.trimSourceURLComment(newSource); // We append correct #sourceURL to script for consistency only. It's not actually needed for things to work correctly. newSource = this.appendSourceURLCommentIfNeeded(newSource); const oldSource = TextUtils.ContentData.ContentData.textOr(await this.requestContentData(), null); if (oldSource === newSource) { return {changed: false, status: Protocol.Debugger.SetScriptSourceResponseStatus.Ok}; } const response = await this.debuggerModel.target().debuggerAgent().invoke_setScriptSource( {scriptId: this.scriptId, scriptSource: newSource, allowTopFrameEditing: true}); if (response.getError()) { // Something went seriously wrong, like the V8 inspector no longer knowing about this script without // shutting down the Debugger agent etc. throw new Error(`Script#editSource failed for script with id ${this.scriptId}: ${response.getError()}`); } if (!response.getError() && response.status === Protocol.Debugger.SetScriptSourceResponseStatus.Ok) { this.#contentPromise = Promise.resolve(new TextUtils.ContentData.ContentData(newSource, /* isBase64 */ false, 'text/javascript')); } this.debuggerModel.dispatchEventToListeners(Events.ScriptSourceWasEdited, {script: this, status: response.status}); return {changed: true, status: response.status, exceptionDetails: response.exceptionDetails}; } rawLocation(lineNumber: number, columnNumber: number): Location|null { if (this.containsLocation(lineNumber, columnNumber)) { return new Location(this.debuggerModel, this.scriptId, lineNumber, columnNumber); } return null; } isInlineScript(): boolean { const startsAtZero = !this.lineOffset && !this.columnOffset; return !this.isWasm() && Boolean(this.sourceURL) && !startsAtZero; } isAnonymousScript(): boolean { return !this.sourceURL; } async setBlackboxedRanges(positions: Protocol.Debugger.ScriptPosition[]): Promise<boolean> { const response = await this.debuggerModel.target().debuggerAgent().invoke_setBlackboxedRanges( {scriptId: this.scriptId, positions}); return !response.getError(); } containsLocation(lineNumber: number, columnNumber: number): boolean { const afterStart = (lineNumber === this.lineOffset && columnNumber >= this.columnOffset) || lineNumber > this.lineOffset; const beforeEnd = lineNumber < this.endLine || (lineNumber === this.endLine && columnNumber <= this.endColumn); return afterStart && beforeEnd; } get frameId(): Protocol.Page.FrameId { // TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration // @ts-expect-error if (typeof this[frameIdSymbol] !== 'string') { // TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration // @ts-expect-error this[frameIdSymbol] = frameIdForScript(this); } // TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration // @ts-expect-error return this[frameIdSymbol]; } /** * @returns true, iff this script originates from a breakpoint/logpoint condition */ get isBreakpointCondition(): boolean { return [COND_BREAKPOINT_SOURCE_URL, LOGPOINT_SOURCE_URL].includes(this.sourceURL); } /** * @returns the currently attached source map for this Script or `undefined` if there is none or it * hasn't loaded yet. */ sourceMap(): SourceMap|undefined { return this.debuggerModel.sourceMapManager().sourceMapForClient(this); } createPageResourceLoadInitiator(): PageResourceLoadInitiator { return {target: this.target(), frameId: this.frameId, initiatorUrl: this.embedderName()}; } debugId(): DebugId|null { return this.buildId as (DebugId | null); } /** * Translates the `rawLocation` from line and column number in terms of what V8 understands * to a script relative location. Specifically this means that for inline `<script>`'s * without a `//# sourceURL=` annotation, the line and column offset of the script * content is subtracted to make the location within the script independent of the * location of the `<script>` tag within the surrounding document. * * @param rawLocation the raw location in terms of what V8 understands. * @returns the script relative line and column number for the {@link rawLocation}. */ rawLocationToRelativeLocation(rawLocation: {lineNumber: number, columnNumber: number}): {lineNumber: number, columnNumber: number}; rawLocationToRelativeLocation(rawLocation: {lineNumber: number, columnNumber: number|undefined}): {lineNumber: number, columnNumber: number|undefined}; rawLocationToRelativeLocation(rawLocation: {lineNumber: number, columnNumber: number|undefined}): {lineNumber: number, columnNumber: number|undefined} { let {lineNumber, columnNumber} = rawLocation; if (!this.hasSourceURL && this.isInlineScript()) { lineNumber -= this.lineOffset; if (lineNumber === 0 && columnNumber !== undefined) { columnNumber -= this.columnOffset; } } return {lineNumber, columnNumber}; } /** * Translates the `relativeLocation` from script relative line and column number to * the raw location in terms of what V8 understands. Specifically this means that for * inline `<script>`'s without a `//# sourceURL=` annotation, the line and column offset * of the script content is added to make the location relative to the start of the * surrounding document. * * @param relativeLocation the script relative location. * @returns the raw location in terms of what V8 understands for the {@link relativeLocation}. */ relativeLocationToRawLocation(relativeLocation: {lineNumber: number, columnNumber: number}): {lineNumber: number, columnNumber: number}; relativeLocationToRawLocation(relativeLocation: {lineNumber: number, columnNumber: number|undefined}): {lineNumber: number, columnNumber: number|undefined}; relativeLocationToRawLocation(relativeLocation: {lineNumber: number, columnNumber: number|undefined}): {lineNumber: number, columnNumber: number|undefined} { let {lineNumber, columnNumber} = relativeLocation; if (!this.hasSourceURL && this.isInlineScript()) { if (lineNumber === 0 && columnNumber !== undefined) { columnNumber += this.columnOffset; } lineNumber += this.lineOffset; } return {lineNumber, columnNumber}; } } const frameIdSymbol = Symbol('frameid'); function frameIdForScript(script: Script): Protocol.Page.FrameId|null { const executionContext = script.executionContext(); if (executionContext) { return executionContext.frameId || null; } // This is to overcome compilation cache which doesn't get reset. const resourceTreeModel = script.debuggerModel.target().model(ResourceTreeModel); if (!resourceTreeModel?.mainFrame) { return null; } return resourceTreeModel.mainFrame.id; } export const sourceURLRegex = /^[\x20\t]*\/\/[@#] sourceURL=\s*(\S*?)\s*$/; export async function disassembleWasm(content: string): Promise<TextUtils.WasmDisassembly.WasmDisassembly> { const worker = Platform.HostRuntime.HOST_RUNTIME.createWorker( new URL('../../entrypoints/wasmparser_worker/wasmparser_worker-entrypoint.js', import.meta.url).toString()); const promise = new Promise<TextUtils.WasmDisassembly.WasmDisassembly>((resolve, reject) => { worker.onmessage = ({data}) => { if ('method' in data) { switch (data.method) { case 'disassemble': if ('error' in data) { reject(data.error); } else if ('result' in data) { const {lines, offsets, functionBodyOffsets} = data.result; resolve(new TextUtils.WasmDisassembly.WasmDisassembly(lines, offsets, functionBodyOffsets)); } break; } } }; worker.onerror = reject; }); worker.postMessage({method: 'disassemble', params: {content}}); try { return await promise; // The await is important here or we terminate the worker too early. } finally { worker.terminate(); } }