summaryrefslogtreecommitdiffstats
path: root/devtools/server/actors/source.js
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/server/actors/source.js')
-rw-r--r--devtools/server/actors/source.js694
1 files changed, 694 insertions, 0 deletions
diff --git a/devtools/server/actors/source.js b/devtools/server/actors/source.js
new file mode 100644
index 0000000000..ff08bcb4c2
--- /dev/null
+++ b/devtools/server/actors/source.js
@@ -0,0 +1,694 @@
+/* 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 { Actor } = require("resource://devtools/shared/protocol.js");
+const { sourceSpec } = require("resource://devtools/shared/specs/source.js");
+
+const {
+ setBreakpointAtEntryPoints,
+} = require("resource://devtools/server/actors/breakpoint.js");
+const {
+ getSourcemapBaseURL,
+} = require("resource://devtools/server/actors/utils/source-map-utils.js");
+const {
+ getDebuggerSourceURL,
+} = require("resource://devtools/server/actors/utils/source-url.js");
+
+loader.lazyRequireGetter(
+ this,
+ "ArrayBufferActor",
+ "resource://devtools/server/actors/array-buffer.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "LongStringActor",
+ "resource://devtools/server/actors/string.js",
+ true
+);
+
+loader.lazyRequireGetter(
+ this,
+ "DevToolsUtils",
+ "resource://devtools/shared/DevToolsUtils.js"
+);
+
+const windowsDrive = /^([a-zA-Z]:)/;
+
+function resolveSourceURL(sourceURL, targetActor) {
+ if (sourceURL) {
+ try {
+ let baseURL;
+ if (targetActor.window) {
+ baseURL = targetActor.window.location?.href;
+ }
+ // For worker, we don't have easy access to location,
+ // so pull extra information directly from the target actor.
+ if (targetActor.workerUrl) {
+ baseURL = targetActor.workerUrl;
+ }
+ return new URL(sourceURL, baseURL || undefined).href;
+ } catch (err) {}
+ }
+
+ return null;
+}
+function getSourceURL(source, targetActor) {
+ // Some eval sources have URLs, but we want to explicitly ignore those because
+ // they are generally useless strings like "eval" or "debugger eval code".
+ let resourceURL = getDebuggerSourceURL(source) || "";
+
+ // Strip out eventual stack trace stored in Source's url.
+ // (not clear if that still happens)
+ resourceURL = resourceURL.split(" -> ").pop();
+
+ // Debugger.Source.url attribute may be of the form:
+ // "http://example.com/foo line 10 > inlineScript"
+ // because of the following function `js::FormatIntroducedFilename`:
+ // https://searchfox.org/mozilla-central/rev/253ae246f642fe9619597f44de3b087f94e45a2d/js/src/vm/JSScript.cpp#1816-1846
+ // This isn't so easy to reproduce, but browser_dbg-breakpoints-popup.js's testPausedInTwoPopups covers this
+ resourceURL = resourceURL.replace(/ line \d+ > .*$/, "");
+
+ // 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, targetActor);
+ if (!result) {
+ result = resolveSourceURL(resourceURL, targetActor) || 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);
+ }
+ }
+
+ // Avoid returning empty string and return null if no URL is found
+ return result || null;
+}
+
+/**
+ * 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.
+ */
+class SourceActor extends Actor {
+ constructor({ source, thread }) {
+ super(thread.conn, sourceSpec);
+
+ this._threadActor = thread;
+ this._url = undefined;
+ this._source = source;
+ this.__isInlineSource = undefined;
+ }
+
+ 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.
+ // Also consider sources loaded from <iframe srcdoc> as independant sources,
+ // because we can't easily fetch the full html content of the srcdoc attribute.
+ this.__isInlineSource =
+ source.introductionType === "inlineScript" &&
+ !resolveSourceURL(source.displayURL, this.threadActor._parent) &&
+ !this.url.startsWith("about:srcdoc");
+ }
+ 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);
+ }
+ 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?.startsWith("moz-extension:")) {
+ try {
+ const extURI = Services.io.newURI(this.url);
+ const policy = WebExtensionPolicy.getByURI(extURI);
+ if (policy) {
+ this._extensionName = policy.name;
+ }
+ } catch (e) {
+ console.warn(`Failed to find extension name for ${this.url} : ${e}`);
+ }
+ }
+ }
+
+ return this._extensionName;
+ }
+
+ get internalSourceId() {
+ return this._source.id;
+ }
+
+ form() {
+ 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";
+ }
+
+ // NOTE: Debugger.Source.prototype.startColumn is 1-based.
+ // Convert to 0-based, while keeping the wasm's column (1) as is.
+ // (bug 1863878)
+ const columnBase = source.introductionType === "wasm" ? 0 : 1;
+
+ 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,
+ isInlineSource: this._isInlineSource,
+ sourceStartLine: source.startLine,
+ sourceStartColumn: source.startColumn - columnBase,
+ sourceLength: source.text?.length,
+ };
+ }
+
+ destroy() {
+ const parent = this.getParent();
+ if (parent && parent.sourceActors) {
+ delete parent.sourceActors[this.actorID];
+ }
+ super.destroy();
+ }
+
+ get _isWasm() {
+ return this._source.introductionType === "wasm";
+ }
+
+ async _getSourceText() {
+ if (this._isWasm) {
+ const wasm = this._source.binary;
+ const buffer = wasm.buffer;
+ DevToolsUtils.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).
+ //
+ // For inline source, we do something special and ignore individual source content.
+ // Instead, each inline source will return the full HTML page content where
+ // the inline source is (i.e. `<script> js source </script>`).
+ //
+ // When using srcdoc attribute on iframes:
+ // <iframe srcdoc="<script> js source </script>"></iframe>
+ // The whole iframe source is going to be considered as an inline source because displayURL is null
+ // and introductionType is inlineScript. But Debugger.Source.text is the only way
+ // to retrieve the source content.
+ 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() {
+ const positions = this._getBreakpointPositions();
+ const lines = new Set();
+ for (const position of positions) {
+ if (!lines.has(position.line)) {
+ lines.add(position.line);
+ }
+ }
+
+ return Array.from(lines);
+ }
+
+ // 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;
+ }
+
+ // NOTE: Debugger.Script.prototype.startColumn is 1-based.
+ // Convert to 0-based, while keeping the wasm's column (1) as is.
+ // (bug 1863878)
+ const columnBase = script.format === "wasm" ? 0 : 1;
+ if (
+ script.startLine > endLine ||
+ script.startLine + lineCount <= startLine ||
+ (script.startLine == endLine &&
+ script.startColumn - columnBase > endColumn)
+ ) {
+ return false;
+ }
+
+ if (
+ lineCount == 1 &&
+ script.startLine == startLine &&
+ script.startColumn - columnBase + 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(query) {
+ const scripts = this._findDebuggeeScripts(
+ query,
+ /* forBreakpointPositions */ true
+ );
+
+ const positions = [];
+ for (const script of scripts) {
+ 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;
+ })
+ );
+ }
+
+ _addScriptBreakpointPositions(query, script, positions) {
+ const {
+ start: { line: startLine = 0, column: startColumn = 0 } = {},
+ end: { line: endLine = Infinity, column: endColumn = Infinity } = {},
+ } = query || {};
+
+ // NOTE: Debugger.Script.prototype.startColumn is 1-based.
+ // Convert to 0-based, while keeping the wasm's column (1) as is.
+ // (bug 1863878)
+ const columnBase = script.format === "wasm" ? 0 : 1;
+
+ const offsets = script.getPossibleBreakpoints();
+ for (const { lineNumber, columnNumber } of offsets) {
+ if (
+ lineNumber < startLine ||
+ (lineNumber === startLine && columnNumber - columnBase < startColumn) ||
+ lineNumber > endLine ||
+ (lineNumber === endLine && columnNumber - columnBase >= endColumn)
+ ) {
+ continue;
+ }
+
+ positions.push({
+ line: lineNumber,
+ column: columnNumber - columnBase,
+ });
+ }
+ }
+
+ getBreakpointPositionsCompressed(query) {
+ const items = 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.
+ */
+ async source() {
+ 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) {
+ throw new Error(
+ "Could not load the source for " +
+ this.url +
+ ".\n" +
+ DevToolsUtils.safeErrorString(error)
+ );
+ }
+ }
+
+ /**
+ * Handler for the "blackbox" packet.
+ */
+ blackbox(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(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(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.
+ */
+ async applyBreakpoint(actor) {
+ const { 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)
+ );
+
+ // NOTE: Debugger.Script.prototype.getPossibleBreakpoints returns
+ // columnNumber in 1-based.
+ // The following code uses columnNumber only for comparing against
+ // other columnNumber, and we don't need to convert to 0-based.
+
+ // 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) {
+ // 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 {
+ // 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) {
+ // NOTE: getPossibleBreakpoints's minColumn/maxColumn parameters are
+ // 1-based.
+ // Convert to 1-based, while keeping the wasm's column (1) as is.
+ // (bug 1863878)
+ const columnBase = script.format === "wasm" ? 0 : 1;
+
+ // Check to see if the script contains a breakpoint position at
+ // this line and column.
+ const possibleBreakpoint = script
+ .getPossibleBreakpoints({
+ line,
+ minColumn: column + columnBase,
+ maxColumn: column + columnBase + 1,
+ })
+ .pop();
+
+ if (possibleBreakpoint) {
+ const { offset } = possibleBreakpoint;
+ entryPoints.push({ script, offsets: [offset] });
+ }
+ }
+ }
+
+ setBreakpointAtEntryPoints(actor, entryPoints);
+ }
+}
+
+exports.SourceActor = SourceActor;