summaryrefslogtreecommitdiffstats
path: root/devtools/server/actors/source.js
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 14:29:10 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 14:29:10 +0000
commit2aa4a82499d4becd2284cdb482213d541b8804dd (patch)
treeb80bf8bf13c3766139fbacc530efd0dd9d54394c /devtools/server/actors/source.js
parentInitial commit. (diff)
downloadfirefox-2aa4a82499d4becd2284cdb482213d541b8804dd.tar.xz
firefox-2aa4a82499d4becd2284cdb482213d541b8804dd.zip
Adding upstream version 86.0.1.upstream/86.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/server/actors/source.js')
-rw-r--r--devtools/server/actors/source.js751
1 files changed, 751 insertions, 0 deletions
diff --git a/devtools/server/actors/source.js b/devtools/server/actors/source.js
new file mode 100644
index 0000000000..601265cd05
--- /dev/null
+++ b/devtools/server/actors/source.js
@@ -0,0 +1,751 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const { Cu } = require("chrome");
+const {
+ setBreakpointAtEntryPoints,
+} = require("devtools/server/actors/breakpoint");
+const { ActorClassWithSpec, Actor } = require("devtools/shared/protocol");
+const DevToolsUtils = require("devtools/shared/DevToolsUtils");
+const { assert } = DevToolsUtils;
+const { sourceSpec } = require("devtools/shared/specs/source");
+const {
+ resolveSourceURL,
+ getSourcemapBaseURL,
+} = require("devtools/server/actors/utils/source-map-utils");
+const {
+ getDebuggerSourceURL,
+} = require("devtools/server/actors/utils/source-url");
+
+loader.lazyRequireGetter(
+ this,
+ "ArrayBufferActor",
+ "devtools/server/actors/array-buffer",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "LongStringActor",
+ "devtools/server/actors/string",
+ true
+);
+
+loader.lazyRequireGetter(this, "Services");
+loader.lazyGetter(
+ this,
+ "WebExtensionPolicy",
+ () => Cu.getGlobalForObject(Cu).WebExtensionPolicy
+);
+
+const windowsDrive = /^([a-zA-Z]:)/;
+
+function getSourceURL(source, window) {
+ // Some eval sources have URLs, but we want to explcitly ignore those because
+ // they are generally useless strings like "eval" or "debugger eval code".
+ const resourceURL =
+ (getDebuggerSourceURL(source) || "").split(" -> ").pop() || null;
+
+ // A "//# sourceURL=" pragma should basically be treated as a source file's
+ // full URL, so that is what we want to use as the base if it is present.
+ // If this is not an absolute URL, this will mean the maps in the file
+ // will not have a valid base URL, but that is up to tooling that
+ let result = resolveSourceURL(source.displayURL, window);
+ if (!result) {
+ result = resolveSourceURL(resourceURL, window) || resourceURL;
+
+ // In XPCShell tests, the source URL isn't actually a URL, it's a file path.
+ // That causes issues because "C:/folder/file.js" is parsed as a URL with
+ // "c:" as the URL scheme, which causes the drive letter to be unexpectedly
+ // lower-cased when the parsed URL is re-serialized. To avoid that, we
+ // detect that case and re-uppercase it again. This is a bit gross and
+ // ideally it seems like XPCShell tests should use file:// URLs for files,
+ // but alas they do not.
+ if (
+ resourceURL &&
+ resourceURL.match(windowsDrive) &&
+ result.slice(0, 2) == resourceURL.slice(0, 2).toLowerCase()
+ ) {
+ result = resourceURL.slice(0, 2) + result.slice(2);
+ }
+ }
+
+ return result;
+}
+
+/**
+ * A SourceActor provides information about the source of a script. Source
+ * actors are 1:1 with Debugger.Source objects.
+ *
+ * @param Debugger.Source source
+ * The source object we are representing.
+ * @param ThreadActor thread
+ * The current thread actor.
+ */
+const SourceActor = ActorClassWithSpec(sourceSpec, {
+ initialize: function({ source, thread }) {
+ Actor.prototype.initialize.call(this, thread.conn);
+
+ this._threadActor = thread;
+ this._url = undefined;
+ this._source = source;
+ this.__isInlineSource = undefined;
+ this._startLineColumnDisplacement = null;
+ },
+
+ get _isInlineSource() {
+ const source = this._source;
+ if (this.__isInlineSource === undefined) {
+ // If the source has a usable displayURL, the source is treated as not
+ // inlined because it has its own URL.
+ this.__isInlineSource =
+ source.introductionType === "inlineScript" &&
+ !resolveSourceURL(source.displayURL, this.threadActor._parent.window);
+ }
+ return this.__isInlineSource;
+ },
+
+ get threadActor() {
+ return this._threadActor;
+ },
+ get sourcesManager() {
+ return this._threadActor.sourcesManager;
+ },
+ get dbg() {
+ return this.threadActor.dbg;
+ },
+ get breakpointActorMap() {
+ return this.threadActor.breakpointActorMap;
+ },
+ get url() {
+ if (this._url === undefined) {
+ this._url = getSourceURL(this._source, this.threadActor._parent.window);
+ }
+ return this._url;
+ },
+
+ get extensionName() {
+ if (this._extensionName === undefined) {
+ this._extensionName = null;
+
+ // Cu is not available for workers and so we are not able to get a
+ // WebExtensionPolicy object
+ if (!isWorker && this.url) {
+ try {
+ const extURI = Services.io.newURI(this.url);
+ if (extURI) {
+ const policy = WebExtensionPolicy.getByURI(extURI);
+ if (policy) {
+ this._extensionName = policy.name;
+ }
+ }
+ } catch (e) {
+ // Ignore
+ }
+ }
+ }
+
+ return this._extensionName;
+ },
+
+ get internalSourceId() {
+ return this._source.id;
+ },
+
+ form: function() {
+ const source = this._source;
+
+ let introductionType = source.introductionType;
+ if (
+ introductionType === "srcScript" ||
+ introductionType === "inlineScript" ||
+ introductionType === "injectedScript"
+ ) {
+ // These three used to be one single type, so here we combine them all
+ // so that clients don't see any change in behavior.
+ introductionType = "scriptElement";
+ }
+
+ return {
+ actor: this.actorID,
+ extensionName: this.extensionName,
+ url: this.url,
+ isBlackBoxed: this.sourcesManager.isBlackBoxed(this.url),
+ sourceMapBaseURL: getSourcemapBaseURL(
+ this.url,
+ this.threadActor._parent.window
+ ),
+ sourceMapURL: source.sourceMapURL,
+ introductionType,
+ };
+ },
+
+ destroy: function() {
+ const parent = this.getParent();
+ if (parent && parent.sourceActors) {
+ delete parent.sourceActors[this.actorID];
+ }
+ Actor.prototype.destroy.call(this);
+ },
+
+ get _isWasm() {
+ return this._source.introductionType === "wasm";
+ },
+
+ _getSourceText: async function() {
+ if (this._isWasm) {
+ const wasm = this._source.binary;
+ const buffer = wasm.buffer;
+ assert(
+ wasm.byteOffset === 0 && wasm.byteLength === buffer.byteLength,
+ "Typed array from wasm source binary must cover entire buffer"
+ );
+ return {
+ content: buffer,
+ contentType: "text/wasm",
+ };
+ }
+
+ // Use `source.text` if it exists, is not the "no source" string, and
+ // the source isn't one that is inlined into some larger file.
+ // It will be "no source" if the Debugger API wasn't able to load
+ // the source because sources were discarded
+ // (javascript.options.discardSystemSource == true).
+ if (this._source.text !== "[no source]" && !this._isInlineSource) {
+ return {
+ content: this.actualText(),
+ contentType: "text/javascript",
+ };
+ }
+
+ return this.sourcesManager.urlContents(
+ this.url,
+ /* partial */ false,
+ /* canUseCache */ this._isInlineSource
+ );
+ },
+
+ // Get the actual text of this source, padded so that line numbers will match
+ // up with the source itself.
+ actualText() {
+ // If the source doesn't start at line 1, line numbers in the client will
+ // not match up with those in the source. Pad the text with blank lines to
+ // fix this. This can show up for sources associated with inline scripts
+ // in HTML created via document.write() calls: the script's source line
+ // number is relative to the start of the written HTML, but we show the
+ // source's content by itself.
+ const padding = this._source.startLine
+ ? "\n".repeat(this._source.startLine - 1)
+ : "";
+ return padding + this._source.text;
+ },
+
+ // Return whether the specified fetched contents includes the actual text of
+ // this source in the expected position.
+ contentMatches(fileContents) {
+ const lineBreak = /\r\n?|\n|\u2028|\u2029/;
+ const contentLines = fileContents.content.split(lineBreak);
+ const sourceLines = this._source.text.split(lineBreak);
+ let line = this._source.startLine - 1;
+ for (const sourceLine of sourceLines) {
+ const contentLine = contentLines[line++] || "";
+ if (!contentLine.includes(sourceLine)) {
+ return false;
+ }
+ }
+ return true;
+ },
+
+ getBreakableLines: async function() {
+ const positions = await this.getBreakpointPositions();
+ const lines = new Set();
+ for (const position of positions) {
+ if (!lines.has(position.line)) {
+ lines.add(position.line);
+ }
+ }
+
+ return Array.from(lines);
+ },
+
+ // For inline <script> tags in HTML pages, the column numbers of the start
+ // line are relative to the column immediately after the opening <script> tag,
+ // rather than the start of the line itself. Calculate the start line and any
+ // column displacement from the start of that line in the HTML file.
+ _getStartLineColumnDisplacement() {
+ if (this._startLineColumnDisplacement) {
+ return this._startLineColumnDisplacement;
+ }
+
+ // Allow fetching the partial contents of the HTML file. When getting the
+ // displacement to install breakpoints on an inline source that just
+ // appeared, we don't expect the HTML file to be completely loaded, and if
+ // we wait for it to load then the script will have already started running.
+ // Fetching the partial contents will only return a promise if we haven't
+ // seen any data for the file, which will only be the case when the debugger
+ // attaches to an existing page. In this case we don't need to get the
+ // displacement synchronously, so it's OK if we yield to the event loop
+ // while the promise resolves.
+ const fileContents = this.sourcesManager.urlContents(
+ this.url,
+ /* partial */ true,
+ /* canUseCache */ this._isInlineSource
+ );
+ if (fileContents.then) {
+ return fileContents.then(contents =>
+ this._setStartLineColumnDisplacement(contents)
+ );
+ }
+ return this._setStartLineColumnDisplacement(fileContents);
+ },
+
+ _setStartLineColumnDisplacement(fileContents) {
+ const d = this._calculateStartLineColumnDisplacement(fileContents);
+ this._startLineColumnDisplacement = d;
+ return d;
+ },
+
+ _calculateStartLineColumnDisplacement(fileContents) {
+ const startLine = this._source.startLine;
+
+ const lineBreak = /\r\n?|\n|\u2028|\u2029/;
+ const fileStartLine =
+ fileContents.content.split(lineBreak)[startLine - 1] || "";
+
+ const sourceContents = this._source.text;
+
+ if (lineBreak.test(sourceContents)) {
+ // The inline script must end the HTML file's line.
+ const firstLine = sourceContents.split(lineBreak)[0];
+ if (firstLine.length && fileStartLine.endsWith(firstLine)) {
+ const column = fileStartLine.length - firstLine.length;
+ return { startLine, column };
+ }
+ return {};
+ }
+
+ // The inline script could be anywhere on the line. Search for its
+ // contents in the line's text. This is a best-guess method and may return
+ // the wrong result if the text appears multiple times on the line, but
+ // the result should make some sense to the user in any case.
+ const column = fileStartLine.indexOf(sourceContents);
+ if (column != -1) {
+ return { startLine, column };
+ }
+ return {};
+ },
+
+ // If a { line, column } location is on the starting line of an inline source,
+ // adjust it upwards or downwards (per |upward|) according to the starting
+ // column displacement.
+ _adjustInlineScriptLocation(location, upward) {
+ if (!this._isInlineSource) {
+ return location;
+ }
+
+ const info = this._getStartLineColumnDisplacement();
+ if (info.then) {
+ return info.then(i =>
+ this._adjustInlineScriptLocationFromDisplacement(i, location, upward)
+ );
+ }
+ return this._adjustInlineScriptLocationFromDisplacement(
+ info,
+ location,
+ upward
+ );
+ },
+
+ _adjustInlineScriptLocationFromDisplacement(info, location, upward) {
+ const { line, column } = location;
+ if (this._startLineColumnDisplacement.startLine == line) {
+ let displacement = this._startLineColumnDisplacement.column;
+ if (!upward) {
+ displacement = -displacement;
+ }
+ return { line, column: column + displacement };
+ }
+ return location;
+ },
+
+ // Get all toplevel scripts in the source. Transitive child scripts must be
+ // found by traversing the child script tree.
+ _getTopLevelDebuggeeScripts() {
+ if (this._scripts) {
+ return this._scripts;
+ }
+
+ let scripts = this.dbg.findScripts({ source: this._source });
+
+ if (!this._isWasm) {
+ // There is no easier way to get the top-level scripts right now, so
+ // we have to build that up the list manually.
+ // Note: It is not valid to simply look for scripts where
+ // `.isFunction == false` because a source may have executed multiple
+ // where some have been GCed and some have not (bug 1627712).
+ const allScripts = new Set(scripts);
+ for (const script of allScripts) {
+ for (const child of script.getChildScripts()) {
+ allScripts.delete(child);
+ }
+ }
+ scripts = [...allScripts];
+ }
+
+ this._scripts = scripts;
+ return scripts;
+ },
+
+ resetDebuggeeScripts() {
+ this._scripts = null;
+ },
+
+ // Get toplevel scripts which contain all breakpoint positions for the source.
+ // This is different from _scripts if we detected that some scripts have been
+ // GC'ed and reparsed the source contents.
+ _getTopLevelBreakpointPositionScripts() {
+ if (this._breakpointPositionScripts) {
+ return this._breakpointPositionScripts;
+ }
+
+ let scripts = this._getTopLevelDebuggeeScripts();
+
+ // We need to find all breakpoint positions, even if scripts associated with
+ // this source have been GC'ed. We detect this by looking for a script which
+ // does not have a function: a source will typically have a top level
+ // non-function script. If this top level script still exists, then it keeps
+ // all its child scripts alive and we will find all breakpoint positions by
+ // scanning the existing scripts. If the top level script has been GC'ed
+ // then we won't find its breakpoint positions, and inner functions may have
+ // been GC'ed as well. In this case we reparse the source and generate a new
+ // and complete set of scripts to look for the breakpoint positions.
+ // Note that in some cases like "new Function(stuff)" there might not be a
+ // top level non-function script, but if there is a non-function script then
+ // it must be at the top level and will keep all other scripts in the source
+ // alive.
+ if (!this._isWasm && !scripts.some(script => !script.isFunction)) {
+ let newScript;
+ try {
+ newScript = this._source.reparse();
+ } catch (e) {
+ // reparse() will throw if the source is not valid JS. This can happen
+ // if this source is the resurrection of a GC'ed source and there are
+ // parse errors in the refetched contents.
+ }
+ if (newScript) {
+ scripts = [newScript];
+ }
+ }
+
+ this._breakpointPositionScripts = scripts;
+ return scripts;
+ },
+
+ // Get all scripts in this source that might include content in the range
+ // specified by the given query.
+ _findDebuggeeScripts(query, forBreakpointPositions) {
+ const scripts = forBreakpointPositions
+ ? this._getTopLevelBreakpointPositionScripts()
+ : this._getTopLevelDebuggeeScripts();
+
+ const {
+ start: { line: startLine = 0, column: startColumn = 0 } = {},
+ end: { line: endLine = Infinity, column: endColumn = Infinity } = {},
+ } = query || {};
+
+ const rv = [];
+ addMatchingScripts(scripts);
+ return rv;
+
+ function scriptMatches(script) {
+ // These tests are approximate, as we can't easily get the script's end
+ // column.
+ let lineCount;
+ try {
+ lineCount = script.lineCount;
+ } catch (err) {
+ // Accessing scripts which were optimized out during parsing can throw
+ // an exception. Tolerate these so that we can still get positions for
+ // other scripts in the source.
+ return false;
+ }
+
+ if (
+ script.startLine > endLine ||
+ script.startLine + lineCount <= startLine ||
+ (script.startLine == endLine && script.startColumn > endColumn)
+ ) {
+ return false;
+ }
+
+ if (
+ lineCount == 1 &&
+ script.startLine == startLine &&
+ script.startColumn + script.sourceLength <= startColumn
+ ) {
+ return false;
+ }
+
+ return true;
+ }
+
+ function addMatchingScripts(childScripts) {
+ for (const script of childScripts) {
+ if (scriptMatches(script)) {
+ rv.push(script);
+ if (script.format === "js") {
+ addMatchingScripts(script.getChildScripts());
+ }
+ }
+ }
+ }
+ },
+
+ getBreakpointPositions: async function(query) {
+ const scripts = this._findDebuggeeScripts(
+ query,
+ /* forBreakpointPositions */ true
+ );
+
+ const positions = [];
+ for (const script of scripts) {
+ await this._addScriptBreakpointPositions(query, script, positions);
+ }
+
+ return (
+ positions
+ // Sort the items by location.
+ .sort((a, b) => {
+ const lineDiff = a.line - b.line;
+ return lineDiff === 0 ? a.column - b.column : lineDiff;
+ })
+ );
+ },
+
+ async _addScriptBreakpointPositions(query, script, positions) {
+ const {
+ start: { line: startLine = 0, column: startColumn = 0 } = {},
+ end: { line: endLine = Infinity, column: endColumn = Infinity } = {},
+ } = query || {};
+
+ const offsets = script.getPossibleBreakpoints();
+ for (const { lineNumber, columnNumber } of offsets) {
+ if (
+ lineNumber < startLine ||
+ (lineNumber === startLine && columnNumber < startColumn) ||
+ lineNumber > endLine ||
+ (lineNumber === endLine && columnNumber >= endColumn)
+ ) {
+ continue;
+ }
+
+ // Adjust columns according to any inline script start column, so that
+ // column breakpoints show up correctly in the UI.
+ const position = await this._adjustInlineScriptLocation(
+ {
+ line: lineNumber,
+ column: columnNumber,
+ },
+ /* upward */ true
+ );
+
+ positions.push(position);
+ }
+ },
+
+ getBreakpointPositionsCompressed: async function(query) {
+ const items = await this.getBreakpointPositions(query);
+ const compressed = {};
+ for (const { line, column } of items) {
+ if (!compressed[line]) {
+ compressed[line] = [];
+ }
+ compressed[line].push(column);
+ }
+ return compressed;
+ },
+
+ /**
+ * Handler for the "onSource" packet.
+ * @return Object
+ * The return of this function contains a field `contentType`, and
+ * a field `source`. `source` can either be an ArrayBuffer or
+ * a LongString.
+ */
+ source: async function() {
+ try {
+ const { content, contentType } = await this._getSourceText();
+ if (
+ typeof content === "object" &&
+ content &&
+ content.constructor &&
+ content.constructor.name === "ArrayBuffer"
+ ) {
+ return {
+ source: new ArrayBufferActor(this.threadActor.conn, content),
+ contentType,
+ };
+ }
+
+ return {
+ source: new LongStringActor(this.threadActor.conn, content),
+ contentType,
+ };
+ } catch (error) {
+ reportError(error, "Got an exception during SA_onSource: ");
+ throw new Error(
+ "Could not load the source for " +
+ this.url +
+ ".\n" +
+ DevToolsUtils.safeErrorString(error)
+ );
+ }
+ },
+
+ /**
+ * Handler for the "blackbox" packet.
+ */
+ blackbox: function(range) {
+ this.sourcesManager.blackBox(this.url, range);
+ if (
+ this.threadActor.state == "paused" &&
+ this.threadActor.youngestFrame &&
+ this.threadActor.youngestFrame.script.url == this.url
+ ) {
+ return true;
+ }
+ return false;
+ },
+
+ /**
+ * Handler for the "unblackbox" packet.
+ */
+ unblackbox: function(range) {
+ this.sourcesManager.unblackBox(this.url, range);
+ },
+
+ /**
+ * Handler for the "setPausePoints" packet.
+ *
+ * @param Array pausePoints
+ * A dictionary of pausePoint objects
+ *
+ * type PausePoints = {
+ * line: {
+ * column: { break?: boolean, step?: boolean }
+ * }
+ * }
+ */
+ setPausePoints: function(pausePoints) {
+ const uncompressed = {};
+ const points = {
+ 0: {},
+ 1: { break: true },
+ 2: { step: true },
+ 3: { break: true, step: true },
+ };
+
+ for (const line in pausePoints) {
+ uncompressed[line] = {};
+ for (const col in pausePoints[line]) {
+ uncompressed[line][col] = points[pausePoints[line][col]];
+ }
+ }
+
+ this.pausePoints = uncompressed;
+ },
+
+ /*
+ * Ensure the given BreakpointActor is set as a breakpoint handler on all
+ * scripts that match its location in the generated source.
+ *
+ * @param BreakpointActor actor
+ * The BreakpointActor to be set as a breakpoint handler.
+ *
+ * @returns A Promise that resolves to the given BreakpointActor.
+ */
+ applyBreakpoint: async function(actor) {
+ let { line, column } = actor.location;
+
+ // Find all entry points that correspond to the given location.
+ const entryPoints = [];
+ if (column === undefined) {
+ // Find all scripts that match the given source actor and line
+ // number.
+ const query = { start: { line }, end: { line } };
+ const scripts = this._findDebuggeeScripts(query).filter(
+ script => !actor.hasScript(script)
+ );
+
+ // This is a line breakpoint, so we add a breakpoint on the first
+ // breakpoint on the line.
+ const lineMatches = [];
+ for (const script of scripts) {
+ const possibleBreakpoints = script.getPossibleBreakpoints({ line });
+ for (const possibleBreakpoint of possibleBreakpoints) {
+ lineMatches.push({ ...possibleBreakpoint, script });
+ }
+ }
+ lineMatches.sort((a, b) => a.columnNumber - b.columnNumber);
+
+ if (lineMatches.length > 0) {
+ // A single Debugger.Source may have _multiple_ Debugger.Scripts
+ // at the same position from multiple evaluations of the source,
+ // so we explicitly want to take all of the matches for the matched
+ // column number.
+ const firstColumn = lineMatches[0].columnNumber;
+ const firstColumnMatches = lineMatches.filter(
+ m => m.columnNumber === firstColumn
+ );
+
+ for (const { script, offset } of firstColumnMatches) {
+ entryPoints.push({ script, offsets: [offset] });
+ }
+ }
+ } else {
+ // Adjust columns according to any inline script start column, to undo
+ // the adjustment performed when sending the breakpoint to the client and
+ // allow the breakpoint to be set correctly in the source (which treats
+ // the location after the <script> tag as column 0).
+ let adjusted = this._adjustInlineScriptLocation(
+ { line, column },
+ /* upward */ false
+ );
+ if (adjusted.then) {
+ adjusted = await adjusted;
+ }
+ line = adjusted.line;
+ column = adjusted.column;
+
+ // Find all scripts that match the given source actor, line,
+ // and column number.
+ const query = { start: { line, column }, end: { line, column } };
+ const scripts = this._findDebuggeeScripts(query).filter(
+ script => !actor.hasScript(script)
+ );
+
+ for (const script of scripts) {
+ // Check to see if the script contains a breakpoint position at
+ // this line and column.
+ const possibleBreakpoint = script
+ .getPossibleBreakpoints({
+ line,
+ minColumn: column,
+ maxColumn: column + 1,
+ })
+ .pop();
+
+ if (possibleBreakpoint) {
+ const { offset } = possibleBreakpoint;
+ entryPoints.push({ script, offsets: [offset] });
+ }
+ }
+ }
+
+ setBreakpointAtEntryPoints(actor, entryPoints);
+ },
+});
+
+exports.SourceActor = SourceActor;