| // 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(); |
| } |
| } |