530 lines
15 KiB
JavaScript
530 lines
15 KiB
JavaScript
/* 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 DevToolsUtils = require("resource://devtools/shared/DevToolsUtils.js");
|
|
const { assert, fetch } = DevToolsUtils;
|
|
const EventEmitter = require("resource://devtools/shared/event-emitter.js");
|
|
const {
|
|
SourceLocation,
|
|
} = require("resource://devtools/server/actors/common.js");
|
|
|
|
loader.lazyRequireGetter(
|
|
this,
|
|
"SourceActor",
|
|
"resource://devtools/server/actors/source.js",
|
|
true
|
|
);
|
|
|
|
/**
|
|
* Matches strings of the form "foo.min.js" or "foo-min.js", etc. If the regular
|
|
* expression matches, we can be fairly sure that the source is minified, and
|
|
* treat it as such.
|
|
*/
|
|
const MINIFIED_SOURCE_REGEXP = /\bmin\.js$/;
|
|
|
|
/**
|
|
* Manages the sources for a thread. Handles URL contents, locations in
|
|
* the sources, etc for ThreadActors.
|
|
*/
|
|
class SourcesManager extends EventEmitter {
|
|
constructor(threadActor) {
|
|
super();
|
|
this._thread = threadActor;
|
|
|
|
this.blackBoxedSources = new Map();
|
|
|
|
// Debugger.Source -> SourceActor
|
|
this._sourceActors = new Map();
|
|
|
|
// URL -> content
|
|
//
|
|
// Any possibly incomplete content that has been loaded for each URL.
|
|
this._urlContents = new Map();
|
|
|
|
// URL -> Promise[]
|
|
//
|
|
// Any promises waiting on a URL to be completely loaded.
|
|
this._urlWaiters = new Map();
|
|
|
|
// Debugger.Source.id -> Debugger.Source
|
|
//
|
|
// The IDs associated with ScriptSources and available via DebuggerSource.id
|
|
// are internal to this process and should not be exposed to the client. This
|
|
// map associates these IDs with the corresponding source, provided the source
|
|
// has not been GC'ed and the actor has been created. This is lazily populated
|
|
// the first time it is needed.
|
|
this._sourcesByInternalSourceId = null;
|
|
|
|
if (!isWorker) {
|
|
Services.obs.addObserver(this, "devtools-html-content");
|
|
}
|
|
}
|
|
|
|
destroy() {
|
|
if (!isWorker) {
|
|
Services.obs.removeObserver(this, "devtools-html-content");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear existing sources so they are recreated on the next access.
|
|
*/
|
|
reset() {
|
|
this._sourceActors = new Map();
|
|
this._urlContents = new Map();
|
|
this._urlWaiters = new Map();
|
|
this._sourcesByInternalSourceId = null;
|
|
}
|
|
|
|
/**
|
|
* Create a source actor representing this source.
|
|
*
|
|
* @param Debugger.Source source
|
|
* The source to make an actor for.
|
|
* @returns a SourceActor representing the source.
|
|
*/
|
|
createSourceActor(source) {
|
|
assert(source, "SourcesManager.prototype.source needs a source");
|
|
|
|
if (this._sourceActors.has(source)) {
|
|
return this._sourceActors.get(source);
|
|
}
|
|
|
|
const actor = new SourceActor({
|
|
thread: this._thread,
|
|
source,
|
|
});
|
|
|
|
this._thread.threadLifetimePool.manage(actor);
|
|
|
|
this._sourceActors.set(source, actor);
|
|
// source.id can be 0 for WASM sources
|
|
if (this._sourcesByInternalSourceId && Number.isInteger(source.id)) {
|
|
this._sourcesByInternalSourceId.set(source.id, source);
|
|
}
|
|
|
|
this.emit("newSource", actor);
|
|
return actor;
|
|
}
|
|
|
|
_getSourceActor(source) {
|
|
if (this._sourceActors.has(source)) {
|
|
return this._sourceActors.get(source);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
hasSourceActor(source) {
|
|
return !!this._getSourceActor(source);
|
|
}
|
|
|
|
getSourceActor(source) {
|
|
const sourceActor = this._getSourceActor(source);
|
|
|
|
if (!sourceActor) {
|
|
throw new Error(
|
|
"getSource: could not find source actor for " + (source.url || "source")
|
|
);
|
|
}
|
|
|
|
return sourceActor;
|
|
}
|
|
|
|
getOrCreateSourceActor(source) {
|
|
// Tolerate the source coming from a different Debugger than the one
|
|
// associated with the thread.
|
|
try {
|
|
source = this._thread.dbg.adoptSource(source);
|
|
} catch (e) {
|
|
// We can't create actors for sources in the same compartment as the
|
|
// thread's Debugger.
|
|
if (/is in the same compartment as this debugger/.test(e)) {
|
|
return null;
|
|
}
|
|
throw e;
|
|
}
|
|
|
|
if (this.hasSourceActor(source)) {
|
|
return this.getSourceActor(source);
|
|
}
|
|
return this.createSourceActor(source);
|
|
}
|
|
|
|
getSourceActorByInternalSourceId(id) {
|
|
if (!this._sourcesByInternalSourceId) {
|
|
this._sourcesByInternalSourceId = new Map();
|
|
for (const source of this._thread.dbg.findSources()) {
|
|
// source.id can be 0 for WASM sources
|
|
if (Number.isInteger(source.id)) {
|
|
this._sourcesByInternalSourceId.set(source.id, source);
|
|
}
|
|
}
|
|
}
|
|
const source = this._sourcesByInternalSourceId.get(id);
|
|
if (source) {
|
|
return this.getOrCreateSourceActor(source);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
getSourceActorsByURL(url) {
|
|
const rv = [];
|
|
if (url) {
|
|
for (const [, actor] of this._sourceActors) {
|
|
if (actor.url === url) {
|
|
rv.push(actor);
|
|
}
|
|
}
|
|
}
|
|
return rv;
|
|
}
|
|
|
|
getSourceActorById(actorId) {
|
|
for (const [, actor] of this._sourceActors) {
|
|
if (actor.actorID == actorId) {
|
|
return actor;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Returns true if the URL likely points to a minified resource, false
|
|
* otherwise.
|
|
*
|
|
* @param String uri
|
|
* The url to test.
|
|
* @returns Boolean
|
|
*/
|
|
_isMinifiedURL(uri) {
|
|
if (!uri) {
|
|
return false;
|
|
}
|
|
|
|
const url = URL.parse(uri);
|
|
if (url) {
|
|
const pathname = url.pathname;
|
|
return MINIFIED_SOURCE_REGEXP.test(
|
|
pathname.slice(pathname.lastIndexOf("/") + 1)
|
|
);
|
|
}
|
|
// Not a valid URL so don't try to parse out the filename, just test the
|
|
// whole thing with the minified source regexp.
|
|
return MINIFIED_SOURCE_REGEXP.test(uri);
|
|
}
|
|
|
|
/**
|
|
* Return the non-source-mapped location of an offset in a script.
|
|
*
|
|
* @param Debugger.Script script
|
|
* The script associated with the offset.
|
|
* @param Number offset
|
|
* Offset within the script of the location.
|
|
* @returns Object
|
|
* Returns an object of the form { source, line, column }
|
|
*/
|
|
getScriptOffsetLocation(script, offset) {
|
|
const { lineNumber, columnNumber } = script.getOffsetMetadata(offset);
|
|
// 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 = script.format === "wasm" ? 0 : 1;
|
|
return new SourceLocation(
|
|
this.createSourceActor(script.source),
|
|
lineNumber,
|
|
columnNumber - columnBase
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Return the non-source-mapped location of the given Debugger.Frame. If the
|
|
* frame does not have a script, the location's properties are all null.
|
|
*
|
|
* @param Debugger.Frame frame
|
|
* The frame whose location we are getting.
|
|
* @returns Object
|
|
* Returns an object of the form { source, line, column }
|
|
*/
|
|
getFrameLocation(frame) {
|
|
if (!frame || !frame.script) {
|
|
return new SourceLocation();
|
|
}
|
|
return this.getScriptOffsetLocation(frame.script, frame.offset);
|
|
}
|
|
|
|
/**
|
|
* Returns true if URL for the given source is black boxed.
|
|
*
|
|
* * @param url String
|
|
* The URL of the source which we are checking whether it is black
|
|
* boxed or not.
|
|
*/
|
|
isBlackBoxed(url, line, column) {
|
|
if (this.blackBoxedSources.size == 0) {
|
|
return false;
|
|
}
|
|
if (!this.blackBoxedSources.has(url)) {
|
|
return false;
|
|
}
|
|
|
|
const ranges = this.blackBoxedSources.get(url);
|
|
|
|
// If we have an entry in the map, but it is falsy, the source is fully blackboxed.
|
|
if (!ranges) {
|
|
return true;
|
|
}
|
|
|
|
const range = ranges.find(r => isLocationInRange({ line, column }, r));
|
|
return !!range;
|
|
}
|
|
|
|
isFrameBlackBoxed(frame) {
|
|
if (this.blackBoxedSources.size == 0) {
|
|
return false;
|
|
}
|
|
const { url, line, column } = this.getFrameLocation(frame);
|
|
return this.isBlackBoxed(url, line, column);
|
|
}
|
|
|
|
clearAllBlackBoxing() {
|
|
this.blackBoxedSources.clear();
|
|
}
|
|
|
|
/**
|
|
* Add the given source URL to the set of sources that are black boxed.
|
|
*
|
|
* @param url String
|
|
* The URL of the source which we are black boxing.
|
|
*/
|
|
blackBox(url, range) {
|
|
if (!range) {
|
|
// blackbox the whole source
|
|
return this.blackBoxedSources.set(url, null);
|
|
}
|
|
|
|
const ranges = this.blackBoxedSources.get(url) || [];
|
|
// ranges are sorted in ascening order
|
|
const index = ranges.findIndex(
|
|
r => r.end.line <= range.start.line && r.end.column <= range.start.column
|
|
);
|
|
|
|
ranges.splice(index + 1, 0, range);
|
|
this.blackBoxedSources.set(url, ranges);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Remove the given source URL to the set of sources that are black boxed.
|
|
*
|
|
* @param url String
|
|
* The URL of the source which we are no longer black boxing.
|
|
*/
|
|
unblackBox(url, range) {
|
|
if (!range) {
|
|
return this.blackBoxedSources.delete(url);
|
|
}
|
|
|
|
const ranges = this.blackBoxedSources.get(url);
|
|
const index = ranges.findIndex(
|
|
r =>
|
|
r.start.line === range.start.line &&
|
|
r.start.column === range.start.column &&
|
|
r.end.line === range.end.line &&
|
|
r.end.column === range.end.column
|
|
);
|
|
|
|
if (index !== -1) {
|
|
ranges.splice(index, 1);
|
|
}
|
|
|
|
if (ranges.length === 0) {
|
|
return this.blackBoxedSources.delete(url);
|
|
}
|
|
|
|
return this.blackBoxedSources.set(url, ranges);
|
|
}
|
|
|
|
/**
|
|
* List all currently registered source actors.
|
|
*
|
|
* @return Iterator<SourceActor>
|
|
*/
|
|
iter() {
|
|
return this._sourceActors.values();
|
|
}
|
|
|
|
/**
|
|
* Listener for new HTML content.
|
|
*/
|
|
observe(subject, topic, data) {
|
|
if (topic == "devtools-html-content") {
|
|
const { parserID, uri, contents, complete } = JSON.parse(data);
|
|
if (this._urlContents.has(uri)) {
|
|
// We received many devtools-html-content events, if we already received one,
|
|
// aggregate the data with the one we already received.
|
|
const existing = this._urlContents.get(uri);
|
|
if (existing.parserID == parserID) {
|
|
assert(!existing.complete);
|
|
existing.content = existing.content + contents;
|
|
existing.complete = complete;
|
|
|
|
// After the HTML has finished loading, resolve any promises
|
|
// waiting for the complete file contents. Waits will only
|
|
// occur when the URL was ever partially loaded.
|
|
if (complete) {
|
|
const waiters = this._urlWaiters.get(uri);
|
|
if (waiters) {
|
|
for (const waiter of waiters) {
|
|
waiter();
|
|
}
|
|
this._urlWaiters.delete(uri);
|
|
}
|
|
}
|
|
}
|
|
} else if (contents) {
|
|
// Ensure that `contents` is non-empty. We may miss all the devtools-html-content events except the last
|
|
// one which has a empty `contents` and complete set to true.
|
|
// This reproduces when opening a same-process iframe. In this particular scenario, we instantiate the target and thread actor
|
|
// on `DOMDocElementInserted` and the HTML document is already parsed, but we still receive this one very last notification.
|
|
this._urlContents.set(uri, {
|
|
content: contents,
|
|
complete,
|
|
contentType: "text/html",
|
|
parserID,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the contents of a URL, fetching it if necessary. If partial is set and
|
|
* any content for the URL has been received, that partial content is returned
|
|
* synchronously.
|
|
*/
|
|
urlContents(url, partial, canUseCache) {
|
|
if (this._urlContents.has(url)) {
|
|
const data = this._urlContents.get(url);
|
|
if (!partial && !data.complete) {
|
|
return new Promise(resolve => {
|
|
if (!this._urlWaiters.has(url)) {
|
|
this._urlWaiters.set(url, []);
|
|
}
|
|
this._urlWaiters.get(url).push(resolve);
|
|
}).then(() => {
|
|
assert(data.complete);
|
|
return {
|
|
content: data.content,
|
|
contentType: data.contentType,
|
|
};
|
|
});
|
|
}
|
|
return {
|
|
content: data.content,
|
|
contentType: data.contentType,
|
|
};
|
|
}
|
|
if (partial) {
|
|
return {
|
|
content: "",
|
|
contentType: "",
|
|
};
|
|
}
|
|
return this._fetchURLContents(url, partial, canUseCache);
|
|
}
|
|
|
|
async _fetchURLContents(url, partial, canUseCache) {
|
|
// Only try the cache if it is currently enabled for the document.
|
|
// Without this check, the cache may return stale data that doesn't match
|
|
// the document shown in the browser.
|
|
let loadFromCache = canUseCache;
|
|
if (canUseCache && this._thread.targetActor.browsingContext) {
|
|
loadFromCache = !(
|
|
this._thread.targetActor.browsingContext.defaultLoadFlags ===
|
|
Ci.nsIRequest.LOAD_BYPASS_CACHE
|
|
);
|
|
}
|
|
|
|
// Fetch the sources with the same principal as the original document
|
|
const win = this._thread.targetActor.window;
|
|
let principal, cacheKey;
|
|
// On xpcshell, we don't have a window but a Sandbox
|
|
if (!isWorker && win instanceof Ci.nsIDOMWindow && win.docShell) {
|
|
const docShell = win.docShell;
|
|
const channel = docShell.currentDocumentChannel;
|
|
principal = channel.loadInfo.loadingPrincipal;
|
|
|
|
// Retrieve the cacheKey in order to load POST requests from cache
|
|
// Note that chrome:// URLs don't support this interface.
|
|
if (
|
|
loadFromCache &&
|
|
docShell.currentDocumentChannel instanceof Ci.nsICacheInfoChannel
|
|
) {
|
|
cacheKey = docShell.currentDocumentChannel.cacheKey;
|
|
}
|
|
}
|
|
|
|
let result;
|
|
try {
|
|
result = await fetch(url, {
|
|
principal,
|
|
cacheKey,
|
|
loadFromCache,
|
|
});
|
|
} catch (error) {
|
|
this._reportLoadSourceError(error);
|
|
throw error;
|
|
}
|
|
|
|
// When we fetch the contents, there is a risk that the contents we get
|
|
// do not match up with the actual text of the sources these contents will
|
|
// be associated with. We want to always show contents that include that
|
|
// actual text (otherwise it will be very confusing or unusable for users),
|
|
// so replace the contents with the actual text if there is a mismatch.
|
|
const actors = [...this._sourceActors.values()].filter(
|
|
// Bug 1907977: some source may not have a valid source text content exposed by spidermonkey
|
|
// and have their text be "[no source]", so avoid falling back to them and consider
|
|
// the request fallback.
|
|
actor => actor.url == url && actor.actualText() != "[no source]"
|
|
);
|
|
if (!actors.every(actor => actor.contentMatches(result))) {
|
|
if (actors.length > 1) {
|
|
// When there are multiple actors we won't be able to show the source
|
|
// for all of them. Ask the user to reload so that we don't have to do
|
|
// any fetching.
|
|
result.content = "Error: Incorrect contents fetched, please reload.";
|
|
} else {
|
|
result.content = actors[0].actualText();
|
|
}
|
|
}
|
|
|
|
this._urlContents.set(url, { ...result, complete: true });
|
|
|
|
return result;
|
|
}
|
|
|
|
_reportLoadSourceError(error) {
|
|
try {
|
|
DevToolsUtils.reportException("SourceActor", error);
|
|
|
|
const lines = JSON.stringify(this.form(), null, 4).split(/\n/g);
|
|
lines.forEach(line => console.error("\t", line));
|
|
} catch (e) {
|
|
// ignore
|
|
}
|
|
}
|
|
}
|
|
|
|
function isLocationInRange({ line, column }, range) {
|
|
return (
|
|
(range.start.line <= line ||
|
|
(range.start.line == line && range.start.column <= column)) &&
|
|
(range.end.line >= line ||
|
|
(range.end.line == line && range.end.column >= column))
|
|
);
|
|
}
|
|
|
|
exports.SourcesManager = SourcesManager;
|