UNPKG

chrome-devtools-frontend

Version:
486 lines (435 loc) • 20.2 kB
// Copyright 2021 The Chromium Authors. All rights reserved. // 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 Protocol from '../../generated/protocol.js'; import * as TextUtils from '../../models/text_utils/text_utils.js'; import type * as Platform from '../../core/platform/platform.js'; import * as Common from '../common/common.js'; import * as i18n from '../i18n/i18n.js'; import { Location, type DebuggerModel, COND_BREAKPOINT_SOURCE_URL, LOGPOINT_SOURCE_URL, Events, } 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 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.', }; 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.ContentProvider.DeferredContent>>>, registry: FinalizationRegistry<string>, }|null = null; export class Script implements TextUtils.ContentProvider.ContentProvider, FrameAssociated { debuggerModel: DebuggerModel; scriptId: Protocol.Runtime.ScriptId; sourceURL: Platform.DevToolsPath.UrlString; lineOffset: number; columnOffset: number; endLine: number; endColumn: number; executionContextId: number; hash: string; readonly #isContentScriptInternal: boolean; readonly #isLiveEditInternal: boolean; sourceMapURL?: string; debugSymbols: Protocol.Debugger.DebugSymbols|null; hasSourceURL: boolean; contentLength: number; originStackTrace: Protocol.Runtime.StackTrace|null; readonly #codeOffsetInternal: number|null; readonly #language: string|null; #contentPromise: Promise<TextUtils.ContentProvider.DeferredContent>|null; readonly #embedderNameInternal: Platform.DevToolsPath.UrlString|null; readonly isModule: boolean|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) { 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.executionContextId = executionContextId; this.hash = hash; this.#isContentScriptInternal = isContentScript; this.#isLiveEditInternal = isLiveEdit; this.sourceMapURL = sourceMapURL; this.debugSymbols = debugSymbols; this.hasSourceURL = hasSourceURL; this.contentLength = length; this.originStackTrace = originStackTrace; this.#codeOffsetInternal = codeOffset; this.#language = scriptLanguage; this.#contentPromise = null; this.#embedderNameInternal = embedderName; } embedderName(): Platform.DevToolsPath.UrlString|null { return this.#embedderNameInternal; } 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.#isContentScriptInternal; } codeOffset(): number|null { return this.#codeOffsetInternal; } 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.#isLiveEditInternal; } contentURL(): Platform.DevToolsPath.UrlString { return this.sourceURL; } contentType(): Common.ResourceType.ResourceType { return Common.ResourceType.resourceTypes.Script; } private async loadTextContent(): Promise<TextUtils.ContentProvider.DeferredContent> { 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 {content: bytecode, isEncoded: true}; } let content: string = scriptSource || ''; if (this.hasSourceURL && this.sourceURL.startsWith('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 {content, isEncoded: false}; } private async loadWasmContent(): Promise<TextUtils.ContentProvider.DeferredContent> { 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; return this.loadTextContent(); } 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]}); } const wasmDisassemblyInfo = new Common.WasmDisassembly.WasmDisassembly( lines.concat(...lineChunks), bytecodeOffsets.concat(...bytecodeOffsetChunks), functionBodyRanges); return {content: '', isEncoded: false, wasmDisassemblyInfo}; } requestContent(): Promise<TextUtils.ContentProvider.DeferredContent> { if (!this.#contentPromise) { const fileSizeToCache = 65535; // We won't bother cacheing files under 64K if (this.hash && !this.#isLiveEditInternal && 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.#codeOffsetInternal, this.hash, ].join(':'); const cachedContentPromise = scriptCacheInstance.cache.get(fullHash)?.deref(); if (cachedContentPromise) { this.#contentPromise = cachedContentPromise; } else { this.#contentPromise = this.requestContentInternal(); scriptCacheInstance.cache.set(fullHash, new WeakRef(this.#contentPromise)); scriptCacheInstance.registry.register(this.#contentPromise, fullHash); } } else { this.#contentPromise = this.requestContentInternal(); } } return this.#contentPromise; } private async requestContentInternal(): Promise<TextUtils.ContentProvider.DeferredContent> { if (!this.scriptId) { return {content: null, error: i18nString(UIStrings.scriptRemovedOrDeleted), isEncoded: false}; } try { return this.isWasm() ? await this.loadWasmContent() : await this.loadTextContent(); } catch (err) { // TODO(bmeurer): Propagate errors as exceptions / rejections. return {content: null, error: i18nString(UIStrings.unableToFetchScriptSource), isEncoded: false}; } } 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 response.arrayBuffer(); } originalContentProvider(): TextUtils.ContentProvider.ContentProvider { return new TextUtils.StaticContentProvider.StaticContentProvider( this.contentURL(), this.contentType(), () => this.requestContent()); } 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 (matches.result || []) .map(match => new TextUtils.ContentProvider.SearchMatch(match.lineNumber, match.lineContent)); } 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 {content: oldSource} = await this.requestContent(); 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({content: newSource, isEncoded: false}); } 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); } createPageResourceLoadInitiator(): PageResourceLoadInitiator { return {target: this.target(), frameId: this.frameId, initiatorUrl: this.embedderName()}; } /** * 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 || !resourceTreeModel.mainFrame) { return null; } return resourceTreeModel.mainFrame.id; } export const sourceURLRegex = /^[\040\t]*\/\/[@#] sourceURL=\s*(\S*?)\s*$/;