diff options
Diffstat (limited to '')
-rw-r--r-- | devtools/shared/loader/DistinctSystemPrincipalLoader.sys.mjs | 45 | ||||
-rw-r--r-- | devtools/shared/loader/Loader.sys.mjs | 209 | ||||
-rw-r--r-- | devtools/shared/loader/base-loader.sys.mjs | 640 | ||||
-rw-r--r-- | devtools/shared/loader/browser-loader-mocks.js | 72 | ||||
-rw-r--r-- | devtools/shared/loader/browser-loader.js | 239 | ||||
-rw-r--r-- | devtools/shared/loader/builtin-modules.js | 203 | ||||
-rw-r--r-- | devtools/shared/loader/loader-plugin-raw.sys.mjs | 39 | ||||
-rw-r--r-- | devtools/shared/loader/moz.build | 21 | ||||
-rw-r--r-- | devtools/shared/loader/worker-loader.js | 536 |
9 files changed, 2004 insertions, 0 deletions
diff --git a/devtools/shared/loader/DistinctSystemPrincipalLoader.sys.mjs b/devtools/shared/loader/DistinctSystemPrincipalLoader.sys.mjs new file mode 100644 index 0000000000..06c33b8891 --- /dev/null +++ b/devtools/shared/loader/DistinctSystemPrincipalLoader.sys.mjs @@ -0,0 +1,45 @@ +/* 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/. */ + +const { DevToolsLoader } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs", + { + // `loadInDevToolsLoader` will import the loader in a special priviledged + // global created for DevTools, which will be reused as the shared global + // to load additional modules for the "DistinctSystemPrincipalLoader". + loadInDevToolsLoader: true, + } +); + +// When debugging system principal resources (JSMs, chrome documents, ...) +// We have to load DevTools actors in another system principal global. +// That's mostly because of spidermonkey's Debugger API which requires +// debuggee and debugger to be in distinct principals. +// +// We try to hold a single instance of this special loader via this API. +// +// @param requester object +// Object/instance which is using the loader. +// The same requester object should be passed to release method. +let systemLoader = null; +const systemLoaderRequesters = new Set(); + +export function useDistinctSystemPrincipalLoader(requester) { + if (!systemLoader) { + systemLoader = new DevToolsLoader({ + useDevToolsLoaderGlobal: true, + }); + systemLoaderRequesters.clear(); + } + systemLoaderRequesters.add(requester); + return systemLoader; +} + +export function releaseDistinctSystemPrincipalLoader(requester) { + systemLoaderRequesters.delete(requester); + if (systemLoaderRequesters.size == 0) { + systemLoader.destroy(); + systemLoader = null; + } +} diff --git a/devtools/shared/loader/Loader.sys.mjs b/devtools/shared/loader/Loader.sys.mjs new file mode 100644 index 0000000000..e34810dde8 --- /dev/null +++ b/devtools/shared/loader/Loader.sys.mjs @@ -0,0 +1,209 @@ +/* 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/. */ + +/** + * Manages the base loader (base-loader.sys.mjs) instance used to load the developer tools. + */ + +import { + Loader, + Require, + resolveURI, + unload, +} from "resource://devtools/shared/loader/base-loader.sys.mjs"; +import { requireRawId } from "resource://devtools/shared/loader/loader-plugin-raw.sys.mjs"; + +export const DEFAULT_SANDBOX_NAME = "DevTools (Module loader)"; + +var gNextLoaderID = 0; + +/** + * The main devtools API. The standard instance of this loader is exported as + * |loader| below, but if a fresh copy of the loader is needed, then a new + * one can also be created. + * + * The two following boolean flags are used to control the sandboxes into + * which the modules are loaded. + * @param invisibleToDebugger boolean + * If true, the modules won't be visible by the Debugger API. + * This typically allows to hide server modules from the debugger panel. + * @param freshCompartment boolean + * If true, the modules will be forced to be loaded in a distinct + * compartment. It is typically used to load the modules in a distinct + * system compartment, different from the main one, which is shared by + * all JSMs, XPCOMs and modules loaded with this flag set to true. + * We use this in order to debug modules loaded in this shared system + * compartment. The debugger actor has to be running in a distinct + * compartment than the context it is debugging. + * @param useDevToolsLoaderGlobal boolean + * If true, the loader will reuse the current global to load other + * modules instead of creating a sandbox with custom options. Cannot be + * used with invisibleToDebugger and/or freshCompartment. + * TODO: This should ultimately replace invisibleToDebugger. + */ +export function DevToolsLoader({ + invisibleToDebugger = false, + freshCompartment = false, + useDevToolsLoaderGlobal = false, +} = {}) { + if (useDevToolsLoaderGlobal && (invisibleToDebugger || freshCompartment)) { + throw new Error( + "Loader cannot use invisibleToDebugger or freshCompartment if useDevToolsLoaderGlobal is true" + ); + } + + const paths = { + // This resource:// URI is only registered when running DAMP tests. + // This is done by: testing/talos/talos/tests/devtools/addon/api.js + "damp-test": "resource://damp-test/content", + // ⚠ DISCUSSION ON DEV-DEVELOPER-TOOLS REQUIRED BEFORE MODIFYING ⚠ + devtools: "resource://devtools", + // ⚠ DISCUSSION ON DEV-DEVELOPER-TOOLS REQUIRED BEFORE MODIFYING ⚠ + // Allow access to xpcshell test items from the loader. + "xpcshell-test": "resource://test", + + // ⚠ DISCUSSION ON DEV-DEVELOPER-TOOLS REQUIRED BEFORE MODIFYING ⚠ + // Allow access to locale data using paths closer to what is + // used in the source tree. + "devtools/client/locales": "chrome://devtools/locale", + "devtools/shared/locales": "chrome://devtools-shared/locale", + "devtools/startup/locales": "chrome://devtools-startup/locale", + "toolkit/locales": "chrome://global/locale", + }; + + const sharedGlobal = useDevToolsLoaderGlobal + ? Cu.getGlobalForObject({}) + : undefined; + this.loader = new Loader({ + paths, + sharedGlobal, + invisibleToDebugger, + freshCompartment, + sandboxName: useDevToolsLoaderGlobal + ? "DevTools (Server Module Loader)" + : DEFAULT_SANDBOX_NAME, + // Make sure `define` function exists. JSON Viewer needs modules in AMD + // format, as it currently uses RequireJS from a content document and + // can't access our usual loaders. So, any modules shared with the JSON + // Viewer should include a define wrapper: + // + // // Make this available to both AMD and CJS environments + // define(function(require, exports, module) { + // ... code ... + // }); + // + // Bug 1248830 will work out a better plan here for our content module + // loading needs, especially as we head towards devtools.html. + supportAMDModules: true, + requireHook: (id, require) => { + if (id.startsWith("raw!") || id.startsWith("theme-loader!")) { + return requireRawId(id, require); + } + return require(id); + }, + }); + + this.require = Require(this.loader, { id: "devtools" }); + + // Various globals are available from ESM, but not from sandboxes, + // inject them into the globals list. + // Changes here should be mirrored to devtools/.eslintrc. + const injectedGlobals = { + CanonicalBrowsingContext, + console, + BrowsingContext, + ChromeWorker, + DebuggerNotificationObserver, + DOMPoint, + DOMQuad, + DOMRect, + fetch, + HeapSnapshot, + IOUtils, + L10nRegistry, + Localization, + NamedNodeMap, + NodeFilter, + PathUtils, + Services, + StructuredCloneHolder, + TelemetryStopwatch, + WebExtensionPolicy, + WindowGlobalParent, + WindowGlobalChild, + }; + for (const name in injectedGlobals) { + this.loader.globals[name] = injectedGlobals[name]; + } + + // Fetch custom pseudo modules and globals + const { modules, globals } = this.require( + "resource://devtools/shared/loader/builtin-modules.js" + ); + + // Register custom pseudo modules to the current loader instance + for (const id in modules) { + const uri = resolveURI(id, this.loader.mapping); + this.loader.modules[uri] = { + get exports() { + return modules[id]; + }, + }; + } + + // Register custom globals to the current loader instance + Object.defineProperties( + this.loader.sharedGlobal, + Object.getOwnPropertyDescriptors(globals) + ); + + // Define the loader id for these two usecases: + // * access via the JSM (this.id) + // let { loader } = ChromeUtils.importESModule("resource://devtools/shared/loader/Loader.sys.mjs"); + // loader.id + this.id = gNextLoaderID++; + // * access via module's `loader` global + // loader.id + globals.loader.id = this.id; + globals.loader.invisibleToDebugger = invisibleToDebugger; + + // Expose lazy helpers on `loader` + // ie. when you use it like that from a JSM: + // let { loader } = ChromeUtils.importESModule("resource://devtools/shared/loader/Loader.sys.mjs"); + // loader.lazyGetter(...); + this.lazyGetter = globals.loader.lazyGetter; + this.lazyServiceGetter = globals.loader.lazyServiceGetter; + this.lazyRequireGetter = globals.loader.lazyRequireGetter; +} + +DevToolsLoader.prototype = { + destroy(reason = "shutdown") { + unload(this.loader, reason); + delete this.loader; + }, + + /** + * Return true if |id| refers to something requiring help from a + * loader plugin. + */ + isLoaderPluginId(id) { + return id.startsWith("raw!"); + }, +}; + +// Export the standard instance of DevToolsLoader used by the tools. +export var loader = new DevToolsLoader({ + /** + * Sets whether the compartments loaded by this instance should be invisible + * to the debugger. Invisibility is needed for loaders that support debugging + * of chrome code. This is true of remote target environments, like Fennec or + * B2G. It is not the default case for desktop Firefox because we offer the + * Browser Toolbox for chrome debugging there, which uses its own, separate + * loader instance. + * @see devtools/client/framework/browser-toolbox/Launcher.sys.mjs + */ + invisibleToDebugger: Services.appinfo.name !== "Firefox", +}); + +export var require = loader.require; diff --git a/devtools/shared/loader/base-loader.sys.mjs b/devtools/shared/loader/base-loader.sys.mjs new file mode 100644 index 0000000000..78998d9b77 --- /dev/null +++ b/devtools/shared/loader/base-loader.sys.mjs @@ -0,0 +1,640 @@ +/* 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/. */ + +/* exported Loader, resolveURI, Module, Require, unload */ + +const systemPrincipal = Components.Constructor( + "@mozilla.org/systemprincipal;1", + "nsIPrincipal" +)(); + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "resProto", + "@mozilla.org/network/protocol;1?name=resource", + "nsIResProtocolHandler" +); + +ChromeUtils.defineModuleGetter( + lazy, + "NetUtil", + "resource://gre/modules/NetUtil.jsm" +); + +// Define some shortcuts. +function* getOwnIdentifiers(x) { + yield* Object.getOwnPropertyNames(x); + yield* Object.getOwnPropertySymbols(x); +} + +function isJSONURI(uri) { + return uri.endsWith(".json"); +} +function isJSMURI(uri) { + return uri.endsWith(".jsm"); +} +function isSYSMJSURI(uri) { + return uri.endsWith(".sys.mjs"); +} +function isJSURI(uri) { + return uri.endsWith(".js"); +} +const AbsoluteRegExp = /^(resource|chrome|file|jar):/; +function isAbsoluteURI(uri) { + return AbsoluteRegExp.test(uri); +} +function isRelative(id) { + return id.startsWith("."); +} + +function readURI(uri) { + const nsURI = lazy.NetUtil.newURI(uri); + if (nsURI.scheme == "resource") { + // Resolve to a real URI, this will catch any obvious bad paths without + // logging assertions in debug builds, see bug 1135219 + uri = lazy.resProto.resolveURI(nsURI); + } + + const stream = lazy.NetUtil.newChannel({ + uri: lazy.NetUtil.newURI(uri, "UTF-8"), + loadUsingSystemPrincipal: true, + }).open(); + const count = stream.available(); + const data = lazy.NetUtil.readInputStreamToString(stream, count, { + charset: "UTF-8", + }); + + stream.close(); + + return data; +} + +// Combines all arguments into a resolved, normalized path +function join(base, ...paths) { + // If this is an absolute URL, we need to normalize only the path portion, + // or we wind up stripping too many slashes and producing invalid URLs. + const match = /^((?:resource|file|chrome)\:\/\/[^\/]*|jar:[^!]+!)(.*)/.exec( + base + ); + if (match) { + return match[1] + normalize([match[2], ...paths].join("/")); + } + + return normalize([base, ...paths].join("/")); +} + +// Function takes set of options and returns a JS sandbox. Function may be +// passed set of options: +// - `name`: A string value which identifies the sandbox in about:memory. Will +// throw exception if omitted. +// - `prototype`: Ancestor for the sandbox that will be created. Defaults to +// `{}`. +// - `invisibleToDebugger`: True, if the sandbox is part of the debugger +// implementation and should not be tracked by debugger API. +// For more details see: +// @see https://searchfox.org/mozilla-central/rev/0948667bc62415d48abff27e1405fb4ab4d65d75/js/xpconnect/idl/xpccomponents.idl#127-245 +function Sandbox(options) { + // Normalize options and rename to match `Cu.Sandbox` expectations. + const sandboxOptions = { + // This will allow exposing Components as well as Cu, Ci and Cr. + wantComponents: true, + + // By default, Sandbox come with a very limited set of global. + // The list of all available symbol names is available over there: + // https://searchfox.org/mozilla-central/rev/31368c7795f44b7a15531d6c5e52dc97f82cf2d5/js/xpconnect/src/Sandbox.cpp#905-997 + // Request to expose all meaningful global here: + wantGlobalProperties: [ + "AbortController", + "atob", + "btoa", + "Blob", + "crypto", + "ChromeUtils", + "CSS", + "CSSRule", + "DOMParser", + "Element", + "Event", + "FileReader", + "FormData", + "Headers", + "InspectorUtils", + "MIDIInputMap", + "MIDIOutputMap", + "Node", + "TextDecoder", + "TextEncoder", + "URL", + "URLSearchParams", + "Window", + "XMLHttpRequest", + ], + + sandboxName: options.name, + sandboxPrototype: "prototype" in options ? options.prototype : {}, + invisibleToDebugger: + "invisibleToDebugger" in options ? options.invisibleToDebugger : false, + freshCompartment: options.freshCompartment || false, + }; + + return Cu.Sandbox(systemPrincipal, sandboxOptions); +} + +// This allows defining some modules in AMD format while retaining CommonJS +// compatibility with this loader by allowing the factory function to have +// access to general CommonJS functions, e.g. +// +// define(function(require, exports, module) { +// ... code ... +// }); +function define(factory) { + factory(this.require, this.exports, this.module); +} + +// Populates `exports` of the given CommonJS `module` object, in the context +// of the given `loader` by evaluating code associated with it. +function load(loader, module) { + const require = Require(loader, module); + + // We expose set of properties defined by `CommonJS` specification via + // prototype of the sandbox. Also globals are deeper in the prototype + // chain so that each module has access to them as well. + const properties = { + require, + module, + exports: module.exports, + }; + if (loader.supportAMDModules) { + properties.define = define; + } + + // Create a new object in the shared global of the loader, that will be used + // as the scope object for this particular module. + const scopeFromSharedGlobal = new loader.sharedGlobal.Object(); + Object.assign(scopeFromSharedGlobal, properties); + + const originalExports = module.exports; + try { + Services.scriptloader.loadSubScript(module.uri, scopeFromSharedGlobal); + } catch (error) { + // loadSubScript sometime throws string errors, which includes no stack. + // At least provide the current stack by re-throwing a real Error object. + if (typeof error == "string") { + if ( + error.startsWith("Error creating URI") || + error.startsWith("Error opening input stream (invalid filename?)") + ) { + throw new Error( + `Module \`${module.id}\` is not found at ${module.uri}` + ); + } + throw new Error( + `Error while loading module \`${module.id}\` at ${module.uri}:` + + "\n" + + error + ); + } + // Otherwise just re-throw everything else which should have a stack + throw error; + } + + // Only freeze the exports object if we created it ourselves. Modules + // which completely replace the exports object and still want it + // frozen need to freeze it themselves. + if (module.exports === originalExports) { + Object.freeze(module.exports); + } + + return module; +} + +// Utility function to normalize module `uri`s so they have `.js` extension. +function normalizeExt(uri) { + if (isJSURI(uri) || isJSONURI(uri) || isJSMURI(uri) || isSYSMJSURI(uri)) { + return uri; + } + return uri + ".js"; +} + +// Utility function to join paths. In common case `base` is a +// `requirer.uri` but in some cases it may be `baseURI`. In order to +// avoid complexity we require `baseURI` with a trailing `/`. +function resolve(id, base) { + if (!isRelative(id)) { + return id; + } + + const baseDir = dirname(base); + + let resolved; + if (baseDir.includes(":")) { + resolved = join(baseDir, id); + } else { + resolved = normalize(`${baseDir}/${id}`); + } + + // Joining and normalizing removes the "./" from relative files. + // We need to ensure the resolution still has the root + if (base.startsWith("./")) { + resolved = "./" + resolved; + } + + return resolved; +} + +function compileMapping(paths) { + // Make mapping array that is sorted from longest path to shortest path. + const mapping = Object.keys(paths) + .sort((a, b) => b.length - a.length) + .map(path => [path, paths[path]]); + + const PATTERN = /([.\\?+*(){}[\]^$])/g; + const escapeMeta = str => str.replace(PATTERN, "\\$1"); + + const patterns = []; + paths = {}; + + for (let [path, uri] of mapping) { + // Strip off any trailing slashes to make comparisons simpler + if (path.endsWith("/")) { + path = path.slice(0, -1); + uri = uri.replace(/\/+$/, ""); + } + + paths[path] = uri; + + // We only want to match path segments explicitly. Examples: + // * "foo/bar" matches for "foo/bar" + // * "foo/bar" matches for "foo/bar/baz" + // * "foo/bar" does not match for "foo/bar-1" + // * "foo/bar/" does not match for "foo/bar" + // * "foo/bar/" matches for "foo/bar/baz" + // + // Check for an empty path, an exact match, or a substring match + // with the next character being a forward slash. + if (path == "") { + patterns.push(""); + } else { + patterns.push(`${escapeMeta(path)}(?=$|/)`); + } + } + + const pattern = new RegExp(`^(${patterns.join("|")})`); + + // This will replace the longest matching path mapping at the start of + // the ID string with its mapped value. + return id => { + return id.replace(pattern, (m0, m1) => paths[m1]); + }; +} + +export function resolveURI(id, mapping) { + // Do not resolve if already a resource URI + if (isAbsoluteURI(id)) { + return normalizeExt(id); + } + + return normalizeExt(mapping(id)); +} + +// Creates version of `require` that will be exposed to the given `module` +// in the context of the given `loader`. Each module gets own limited copy +// of `require` that is allowed to load only a modules that are associated +// with it during link time. +export function Require(loader, requirer) { + const { modules, mapping, mappingCache, requireHook } = loader; + + function require(id) { + if (!id) { + // Throw if `id` is not passed. + throw Error( + "You must provide a module name when calling require() from " + + requirer.id, + requirer.uri + ); + } + + if (requireHook) { + return requireHook(id, _require); + } + + return _require(id); + } + + function _require(id) { + let { uri, requirement } = getRequirements(id); + + let module = null; + // If module is already cached by loader then just use it. + if (uri in modules) { + module = modules[uri]; + } else if (isJSMURI(uri)) { + module = modules[uri] = Module(requirement, uri); + module.exports = ChromeUtils.import(uri); + } else if (isSYSMJSURI(uri)) { + module = modules[uri] = Module(requirement, uri); + module.exports = ChromeUtils.importESModule(uri); + } else if (isJSONURI(uri)) { + let data; + + // First attempt to load and parse json uri + // ex: `test.json` + // If that doesn"t exist, check for `test.json.js` + // for node parity + try { + data = JSON.parse(readURI(uri)); + module = modules[uri] = Module(requirement, uri); + module.exports = data; + } catch (err) { + // If error thrown from JSON parsing, throw that, do not + // attempt to find .json.js file + if (err && /JSON\.parse/.test(err.message)) { + throw err; + } + uri = uri + ".js"; + } + } + + // If not yet cached, load and cache it. + // We also freeze module to prevent it from further changes + // at runtime. + if (!(uri in modules)) { + // Many of the loader's functionalities are dependent + // on modules[uri] being set before loading, so we set it and + // remove it if we have any errors. + module = modules[uri] = Module(requirement, uri); + try { + Object.freeze(load(loader, module)); + } catch (e) { + // Clear out modules cache so we can throw on a second invalid require + delete modules[uri]; + throw e; + } + } + + return module.exports; + } + + // Resolution function taking a module name/path and + // returning a resourceURI and a `requirement` used by the loader. + // Used by both `require` and `require.resolve`. + function getRequirements(id) { + if (!id) { + // Throw if `id` is not passed. + throw Error( + "you must provide a module name when calling require() from " + + requirer.id, + requirer.uri + ); + } + + let requirement, uri; + + if (modules[id]) { + uri = requirement = id; + } else if (requirer) { + // Resolve `id` to its requirer if it's relative. + requirement = resolve(id, requirer.id); + } else { + requirement = id; + } + + // Resolves `uri` of module using loaders resolve function. + if (!uri) { + if (mappingCache.has(requirement)) { + uri = mappingCache.get(requirement); + } else { + uri = resolveURI(requirement, mapping); + mappingCache.set(requirement, uri); + } + } + + // Throw if `uri` can not be resolved. + if (!uri) { + throw Error( + "Module: Can not resolve '" + + id + + "' module required by " + + requirer.id + + " located at " + + requirer.uri, + requirer.uri + ); + } + + return { uri, requirement }; + } + + // Expose the `resolve` function for this `Require` instance + require.resolve = _require.resolve = function (id) { + const { uri } = getRequirements(id); + return uri; + }; + + // This is like webpack's require.context. It returns a new require + // function that prepends the prefix to any requests. + require.context = prefix => { + return id => { + return require(prefix + id); + }; + }; + + return require; +} + +// Makes module object that is made available to CommonJS modules when they +// are evaluated, along with `exports` and `require`. +export function Module(id, uri) { + return Object.create(null, { + id: { enumerable: true, value: id }, + exports: { + enumerable: true, + writable: true, + value: Object.create(null), + configurable: true, + }, + uri: { value: uri }, + }); +} + +// Takes `loader`, and unload `reason` string and notifies all observers that +// they should cleanup after them-self. +export function unload(loader, reason) { + // subject is a unique object created per loader instance. + // This allows any code to cleanup on loader unload regardless of how + // it was loaded. To handle unload for specific loader subject may be + // asserted against loader.destructor or require("@loader/unload") + // Note: We don not destroy loader's module cache or sandboxes map as + // some modules may do cleanup in subsequent turns of event loop. Destroying + // cache may cause module identity problems in such cases. + const subject = { wrappedJSObject: loader.destructor }; + Services.obs.notifyObservers(subject, "devtools:loader:destroy", reason); +} + +// Function makes new loader that can be used to load CommonJS modules. +// Loader takes following options: +// - `paths`: Mandatory dictionary of require path mapped to absolute URIs. +// Object keys are path prefix used in require(), values are URIs where each +// prefix should be mapped to. +// - `globals`: Optional map of globals, that all module scopes will inherit +// from. Map is also exposed under `globals` property of the returned loader +// so it can be extended further later. Defaults to `{}`. +// - `sandboxName`: String, name of the sandbox displayed in about:memory. +// - `invisibleToDebugger`: Boolean. Should be true when loading debugger +// modules, in order to ignore them from the Debugger API. +// - `sandboxPrototype`: Object used to define globals on all module's +// sandboxes. +// - `requireHook`: Optional function used to replace native require function +// from loader. This function receive the module path as first argument, +// and native require method as second argument. +export function Loader(options) { + let { paths, globals } = options; + if (!globals) { + globals = {}; + } + + // We create an identity object that will be dispatched on an unload + // event as subject. This way unload listeners will be able to assert + // which loader is unloaded. Please note that we intentionally don"t + // use `loader` as subject to prevent a loader access leakage through + // observer notifications. + const destructor = Object.create(null); + + const mapping = compileMapping(paths); + + // Define pseudo modules. + const builtinModuleExports = { + "@loader/unload": destructor, + "@loader/options": options, + }; + + const modules = {}; + for (const id of Object.keys(builtinModuleExports)) { + // We resolve `uri` from `id` since modules are cached by `uri`. + const uri = resolveURI(id, mapping); + const module = Module(id, uri); + + // Lazily expose built-in modules in order to + // allow them to be loaded lazily. + Object.defineProperty(module, "exports", { + enumerable: true, + get() { + return builtinModuleExports[id]; + }, + }); + + modules[uri] = module; + } + + let sharedGlobal; + if (options.sharedGlobal) { + sharedGlobal = options.sharedGlobal; + } else { + // Create the unique sandbox we will be using for all modules, + // so that we prevent creating a new compartment per module. + // The side effect is that all modules will share the same + // global objects. + sharedGlobal = Sandbox({ + name: options.sandboxName || "DevTools", + invisibleToDebugger: options.invisibleToDebugger || false, + prototype: options.sandboxPrototype || globals, + freshCompartment: options.freshCompartment, + }); + } + + if (options.sharedGlobal || options.sandboxPrototype) { + // If we were given a sharedGlobal or a sandboxPrototype, we have to define + // the globals on the shared global directly. Note that this will not work + // for callers who depend on being able to add globals after the loader was + // created. + for (const name of getOwnIdentifiers(globals)) { + Object.defineProperty( + sharedGlobal, + name, + Object.getOwnPropertyDescriptor(globals, name) + ); + } + } + + // Loader object is just a representation of a environment + // state. We mark its properties non-enumerable + // as they are pure implementation detail that no one should rely upon. + const returnObj = { + destructor: { enumerable: false, value: destructor }, + globals: { enumerable: false, value: globals }, + mapping: { enumerable: false, value: mapping }, + mappingCache: { enumerable: false, value: new Map() }, + // Map of module objects indexed by module URIs. + modules: { enumerable: false, value: modules }, + sharedGlobal: { enumerable: false, value: sharedGlobal }, + supportAMDModules: { + enumerable: false, + value: options.supportAMDModules || false, + }, + // Whether the modules loaded should be ignored by the debugger + invisibleToDebugger: { + enumerable: false, + value: options.invisibleToDebugger || false, + }, + requireHook: { + enumerable: false, + writable: true, + value: options.requireHook, + }, + }; + + return Object.create(null, returnObj); +} + +// NB: These methods are from the UNIX implementation of OS.Path. Refactoring +// this module to not use path methods on stringly-typed URIs is +// non-trivial. +function dirname(path) { + let index = path.lastIndexOf("/"); + if (index == -1) { + return "."; + } + while (index >= 0 && path[index] == "/") { + --index; + } + return path.slice(0, index + 1); +} + +function normalize(path) { + const stack = []; + let absolute; + if (path.length >= 0 && path[0] == "/") { + absolute = true; + } else { + absolute = false; + } + path.split("/").forEach(function (v) { + switch (v) { + case "": + case ".": // fallthrough + break; + case "..": + if (!stack.length) { + if (absolute) { + throw new Error("Path is ill-formed: attempting to go past root"); + } else { + stack.push(".."); + } + } else if (stack[stack.length - 1] == "..") { + stack.push(".."); + } else { + stack.pop(); + } + break; + default: + stack.push(v); + } + }); + const string = stack.join("/"); + return absolute ? "/" + string : string; +} diff --git a/devtools/shared/loader/browser-loader-mocks.js b/devtools/shared/loader/browser-loader-mocks.js new file mode 100644 index 0000000000..c8fe528ee5 --- /dev/null +++ b/devtools/shared/loader/browser-loader-mocks.js @@ -0,0 +1,72 @@ +/* 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"; + +// Map of mocked modules, keys are absolute URIs for devtools modules such as +// "resource://devtools/path/to/mod.js, values are objects (anything passed to +// setMockedModule technically). +const _mocks = {}; + +/** + * Retrieve a mocked module matching the provided uri, eg "resource://path/to/file.js". + */ +function getMockedModule(uri) { + return _mocks[uri]; +} +exports.getMockedModule = getMockedModule; + +/** + * Module paths are transparently provided with or without ".js" when using the loader, + * normalize the user-provided module paths to always have modules ending with ".js". + */ +function _getUriForModulePath(modulePath) { + // Assume js modules and add the .js extension if missing. + if (!modulePath.endsWith(".js")) { + modulePath = modulePath + ".js"; + } + + // Add resource:// scheme if no scheme is specified. + if (!modulePath.includes("://")) { + modulePath = "resource://" + modulePath; + } + + return modulePath; +} + +/** + * Assign a mock object to the provided module path. + * @param mock + * Plain JavaScript object that will implement the expected API for the mocked + * module. + * @param modulePath + * The module path should be the absolute module path, starting with `devtools`: + * "devtools/client/some-panel/some-module" + */ +function setMockedModule(mock, modulePath) { + const uri = _getUriForModulePath(modulePath); + _mocks[uri] = new Proxy(mock, { + get(target, key) { + if (typeof target[key] === "function") { + // Functions are wrapped to be able to update the methods during the test, even if + // the methods were imported with destructuring. For instance: + // `const { someMethod } = require("devtools/client/shared/my-module");` + return function () { + return target[key].apply(target, arguments); + }; + } + return target[key]; + }, + }); +} +exports.setMockedModule = setMockedModule; + +/** + * Remove any mock object defined for the provided absolute module path. + */ +function removeMockedModule(modulePath) { + const uri = _getUriForModulePath(modulePath); + delete _mocks[uri]; +} +exports.removeMockedModule = removeMockedModule; diff --git a/devtools/shared/loader/browser-loader.js b/devtools/shared/loader/browser-loader.js new file mode 100644 index 0000000000..f42b009e17 --- /dev/null +++ b/devtools/shared/loader/browser-loader.js @@ -0,0 +1,239 @@ +/* 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 BaseLoader = ChromeUtils.importESModule( + "resource://devtools/shared/loader/base-loader.sys.mjs" +); +const { require: devtoolsRequire, loader } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" +); +const flags = devtoolsRequire("devtools/shared/flags"); +const { joinURI } = devtoolsRequire("devtools/shared/path"); +const { assert } = devtoolsRequire("devtools/shared/DevToolsUtils"); + +const lazy = {}; + +loader.lazyRequireGetter( + lazy, + "getMockedModule", + "resource://devtools/shared/loader/browser-loader-mocks.js", + {} +); + +const BROWSER_BASED_DIRS = [ + "resource://devtools/client/inspector/boxmodel", + "resource://devtools/client/inspector/changes", + "resource://devtools/client/inspector/computed", + "resource://devtools/client/inspector/events", + "resource://devtools/client/inspector/flexbox", + "resource://devtools/client/inspector/fonts", + "resource://devtools/client/inspector/grids", + "resource://devtools/client/inspector/layout", + "resource://devtools/client/inspector/markup", + "resource://devtools/client/jsonview", + "resource://devtools/client/netmonitor/src/utils", + "resource://devtools/client/shared/fluent-l10n", + "resource://devtools/client/shared/redux", + "resource://devtools/client/shared/vendor", +]; + +const COMMON_LIBRARY_DIRS = ["resource://devtools/client/shared/vendor"]; + +// Any directory that matches the following regular expression +// is also considered as browser based module directory. +// ('resource://devtools/client/.*/components/') +// +// An example: +// * `resource://devtools/client/inspector/components` +// * `resource://devtools/client/inspector/shared/components` +const browserBasedDirsRegExp = + /^resource\:\/\/devtools\/client\/\S*\/components\//; + +/* + * Create a loader to be used in a browser environment. This evaluates + * modules in their own environment, but sets window (the normal + * global object) as the sandbox prototype, so when a variable is not + * defined it checks `window` before throwing an error. This makes all + * browser APIs available to modules by default, like a normal browser + * environment, but modules are still evaluated in their own scope. + * + * Another very important feature of this loader is that it *only* + * deals with modules loaded from under `baseURI`. Anything loaded + * outside of that path will still be loaded from the devtools loader, + * so all system modules are still shared and cached across instances. + * An exception to this is anything under + * `devtools/client/shared/{vendor/components}`, which is where shared libraries + * and React components live that should be evaluated in a browser environment. + * + * @param string baseURI + * Base path to load modules from. If null or undefined, only + * the shared vendor/components modules are loaded with the browser + * loader. + * @param Object window + * The window instance to evaluate modules within + * @param Boolean useOnlyShared + * If true, ignores `baseURI` and only loads the shared + * BROWSER_BASED_DIRS via BrowserLoader. + * @return Object + * An object with two properties: + * - loader: the Loader instance + * - require: a function to require modules with + */ +function BrowserLoader(options) { + const browserLoaderBuilder = new BrowserLoaderBuilder(options); + return { + loader: browserLoaderBuilder.loader, + require: browserLoaderBuilder.require, + }; +} + +/** + * Private class used to build the Loader instance and require method returned + * by BrowserLoader(baseURI, window). + * + * @param string baseURI + * Base path to load modules from. + * @param Function commonLibRequire + * Require function that should be used to load common libraries, like React. + * Allows for sharing common modules between tools, instead of loading a new + * instance into each tool. For example, pass "toolbox.browserRequire" here. + * @param Boolean useOnlyShared + * If true, ignores `baseURI` and only loads the shared + * BROWSER_BASED_DIRS via BrowserLoader. + * @param Object window + * The window instance to evaluate modules within + */ +function BrowserLoaderBuilder({ + baseURI, + commonLibRequire, + useOnlyShared, + window, +}) { + assert( + !!baseURI !== !!useOnlyShared, + "Cannot use both `baseURI` and `useOnlyShared`." + ); + + const loaderOptions = devtoolsRequire("@loader/options"); + + const opts = { + sandboxPrototype: window, + sandboxName: "DevTools (UI loader)", + paths: loaderOptions.paths, + invisibleToDebugger: loaderOptions.invisibleToDebugger, + // Make sure `define` function exists. This allows defining some modules + // in AMD format while retaining CommonJS compatibility through this hook. + // JSON Viewer needs modules in AMD format, as it currently uses RequireJS + // from a content document and can't access our usual loaders. So, any + // modules shared with the JSON Viewer should include a define wrapper: + // + // // Make this available to both AMD and CJS environments + // define(function(require, exports, module) { + // ... code ... + // }); + // + // Bug 1248830 will work out a better plan here for our content module + // loading needs, especially as we head towards devtools.html. + supportAMDModules: true, + requireHook: (id, require) => { + // If |id| requires special handling, simply defer to devtools + // immediately. + if (loader.isLoaderPluginId(id)) { + return devtoolsRequire(id); + } + + const uri = require.resolve(id); + + // The mocks can be set from tests using browser-loader-mocks.js setMockedModule(). + // If there is an entry for a given uri in the `mocks` object, return it instead of + // requiring the module. + if (flags.testing && lazy.getMockedModule(uri)) { + return lazy.getMockedModule(uri); + } + + if ( + commonLibRequire && + COMMON_LIBRARY_DIRS.some(dir => uri.startsWith(dir)) + ) { + return commonLibRequire(uri); + } + + // Check if the URI matches one of hardcoded paths or a regexp. + const isBrowserDir = + BROWSER_BASED_DIRS.some(dir => uri.startsWith(dir)) || + uri.match(browserBasedDirsRegExp) != null; + + if ((useOnlyShared || !uri.startsWith(baseURI)) && !isBrowserDir) { + return devtoolsRequire(uri); + } + + return require(uri); + }, + globals: { + // Allow modules to use the window's console to ensure logs appear in a + // tab toolbox, if one exists, instead of just the browser console. + console: window.console, + // Allow modules to use the DevToolsLoader lazy loading helpers. + loader: { + lazyGetter: loader.lazyGetter, + lazyServiceGetter: loader.lazyServiceGetter, + lazyRequireGetter: this.lazyRequireGetter.bind(this), + }, + }, + }; + + const mainModule = BaseLoader.Module(baseURI, joinURI(baseURI, "main.js")); + this.loader = BaseLoader.Loader(opts); + // When running tests, expose the BrowserLoader instance for metrics tests. + if (flags.testing) { + window.getBrowserLoaderForWindow = () => this; + } + this.require = BaseLoader.Require(this.loader, mainModule); +} + +BrowserLoaderBuilder.prototype = { + /** + * Define a getter property on the given object that requires the given + * module. This enables delaying importing modules until the module is + * actually used. + * + * Several getters can be defined at once by providing an array of + * properties and enabling destructuring. + * + * @param { Object } obj + * The object to define the property on. + * @param { String | Array<String> } properties + * String: Name of the property for the getter. + * Array<String>: When destructure is true, properties can be an array of + * strings to create several getters at once. + * @param { String } module + * The module path. + * @param { Boolean } destructure + * Pass true if the property name is a member of the module's exports. + */ + lazyRequireGetter(obj, properties, module, destructure) { + if (Array.isArray(properties) && !destructure) { + throw new Error( + "Pass destructure=true to call lazyRequireGetter with an array of properties" + ); + } + + if (!Array.isArray(properties)) { + properties = [properties]; + } + + for (const property of properties) { + loader.lazyGetter(obj, property, () => { + return destructure + ? this.require(module)[property] + : this.require(module || property); + }); + } + }, +}; + +this.BrowserLoader = BrowserLoader; + +this.EXPORTED_SYMBOLS = ["BrowserLoader"]; diff --git a/devtools/shared/loader/builtin-modules.js b/devtools/shared/loader/builtin-modules.js new file mode 100644 index 0000000000..7dc04e5e98 --- /dev/null +++ b/devtools/shared/loader/builtin-modules.js @@ -0,0 +1,203 @@ +/* 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"; + +/** + * This module defines custom globals injected in all our modules and also + * pseudo modules that aren't separate files but just dynamically set values. + * + * Note that some globals are being defined by base-loader.sys.mjs via wantGlobalProperties property. + * + * As it does so, the module itself doesn't have access to these globals, + * nor the pseudo modules. Be careful to avoid loading any other js module as + * they would also miss them. + */ + +const systemPrincipal = Services.scriptSecurityManager.getSystemPrincipal(); + +/** + * Defines a getter on a specified object that will be created upon first use. + * + * @param object + * The object to define the lazy getter on. + * @param name + * The name of the getter to define on object. + * @param lambda + * A function that returns what the getter should return. This will + * only ever be called once. + */ +function defineLazyGetter(object, name, lambda) { + Object.defineProperty(object, name, { + get() { + // Redefine this accessor property as a data property. + // Delete it first, to rule out "too much recursion" in case object is + // a proxy whose defineProperty handler might unwittingly trigger this + // getter again. + delete object[name]; + const value = lambda.apply(object); + Object.defineProperty(object, name, { + value, + writable: true, + configurable: true, + enumerable: true, + }); + return value; + }, + configurable: true, + enumerable: true, + }); +} + +/** + * Defines a getter on a specified object for a service. The service will not + * be obtained until first use. + * + * @param object + * The object to define the lazy getter on. + * @param name + * The name of the getter to define on object for the service. + * @param contract + * The contract used to obtain the service. + * @param interfaceName + * The name of the interface to query the service to. + */ +function defineLazyServiceGetter(object, name, contract, interfaceName) { + defineLazyGetter(object, name, function () { + return Cc[contract].getService(Ci[interfaceName]); + }); +} + +/** + * Define a getter property on the given object that requires the given + * module. This enables delaying importing modules until the module is + * actually used. + * + * Several getters can be defined at once by providing an array of + * properties and enabling destructuring. + * + * @param { Object } obj + * The object to define the property on. + * @param { String | Array<String> } properties + * String: Name of the property for the getter. + * Array<String>: When destructure is true, properties can be an array of + * strings to create several getters at once. + * @param { String } module + * The module path. + * @param { Boolean } destructure + * Pass true if the property name is a member of the module's exports. + */ +function lazyRequireGetter(obj, properties, module, destructure) { + if (Array.isArray(properties) && !destructure) { + throw new Error( + "Pass destructure=true to call lazyRequireGetter with an array of properties" + ); + } + + if (!Array.isArray(properties)) { + properties = [properties]; + } + + for (const property of properties) { + defineLazyGetter(obj, property, () => { + return destructure + ? require(module)[property] + : require(module || property); + }); + } +} + +// List of pseudo modules exposed to all devtools modules. +exports.modules = { + HeapSnapshot, + // Expose "chrome" Promise, which aren't related to any document + // and so are never frozen, even if the browser loader module which + // pull it is destroyed. See bug 1402779. + Promise, + TelemetryStopwatch, +}; + +defineLazyGetter(exports.modules, "Debugger", () => { + const global = Cu.getGlobalForObject(this); + // Debugger may already have been added. + if (global.Debugger) { + return global.Debugger; + } + const { addDebuggerToGlobal } = ChromeUtils.importESModule( + "resource://gre/modules/jsdebugger.sys.mjs" + ); + addDebuggerToGlobal(global); + return global.Debugger; +}); + +defineLazyGetter(exports.modules, "ChromeDebugger", () => { + // Sandbox are memory expensive, so we should create as little as possible. + const debuggerSandbox = Cu.Sandbox(systemPrincipal, { + // This sandbox is used for the ChromeDebugger implementation. + // As we want to load the `Debugger` API for debugging chrome contexts, + // we have to ensure loading it in a distinct compartment from its debuggee. + freshCompartment: true, + }); + + const { addDebuggerToGlobal } = ChromeUtils.importESModule( + "resource://gre/modules/jsdebugger.sys.mjs" + ); + addDebuggerToGlobal(debuggerSandbox); + return debuggerSandbox.Debugger; +}); + +defineLazyGetter(exports.modules, "xpcInspector", () => { + return Cc["@mozilla.org/jsinspector;1"].getService(Ci.nsIJSInspector); +}); + +// List of all custom globals exposed to devtools modules. +// Changes here should be mirrored to devtools/.eslintrc. +exports.globals = { + isWorker: false, + loader: { + lazyGetter: defineLazyGetter, + lazyServiceGetter: defineLazyServiceGetter, + lazyRequireGetter, + // Defined by Loader.sys.mjs + id: null, + }, +}; +// DevTools loader copy globals property descriptors on each module global +// object so that we have to memoize them from here in order to instantiate each +// global only once. +// `globals` is a cache object on which we put all global values +// and we set getters on `exports.globals` returning `globals` values. +const globals = {}; +function lazyGlobal(name, getter) { + defineLazyGetter(globals, name, getter); + Object.defineProperty(exports.globals, name, { + get() { + return globals[name]; + }, + configurable: true, + enumerable: true, + }); +} + +// Lazily define a few things so that the corresponding modules are only loaded +// when used. +lazyGlobal("clearTimeout", () => { + return ChromeUtils.importESModule("resource://gre/modules/Timer.sys.mjs") + .clearTimeout; +}); +lazyGlobal("setTimeout", () => { + return ChromeUtils.importESModule("resource://gre/modules/Timer.sys.mjs") + .setTimeout; +}); +lazyGlobal("clearInterval", () => { + return ChromeUtils.importESModule("resource://gre/modules/Timer.sys.mjs") + .clearInterval; +}); +lazyGlobal("setInterval", () => { + return ChromeUtils.importESModule("resource://gre/modules/Timer.sys.mjs") + .setInterval; +}); +lazyGlobal("WebSocket", () => { + return Services.appShell.hiddenDOMWindow.WebSocket; +}); diff --git a/devtools/shared/loader/loader-plugin-raw.sys.mjs b/devtools/shared/loader/loader-plugin-raw.sys.mjs new file mode 100644 index 0000000000..a7aa630ce5 --- /dev/null +++ b/devtools/shared/loader/loader-plugin-raw.sys.mjs @@ -0,0 +1,39 @@ +/* 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/. */ + +const { NetUtil } = ChromeUtils.import("resource://gre/modules/NetUtil.jsm"); + +/** + * A function that can be used as part of a require hook for a + * loader.js Loader. + * This function handles "raw!" and "theme-loader!" requires. + * See also: https://github.com/webpack/raw-loader. + */ +export const requireRawId = function (id, require) { + const index = id.indexOf("!"); + const rawId = id.slice(index + 1); + let uri = require.resolve(rawId); + // If the original string did not end with ".js", then + // require.resolve might have added the suffix. We don't want to + // add a suffix for a raw load (if needed the caller can specify it + // manually), so remove it here. + if (!id.endsWith(".js") && uri.endsWith(".js")) { + uri = uri.slice(0, -3); + } + + const stream = NetUtil.newChannel({ + uri: NetUtil.newURI(uri, "UTF-8"), + loadUsingSystemPrincipal: true, + }).open(); + + const count = stream.available(); + const data = NetUtil.readInputStreamToString(stream, count, { + charset: "UTF-8", + }); + stream.close(); + + // For the time being it doesn't seem worthwhile to cache the + // result here. + return data; +}; diff --git a/devtools/shared/loader/moz.build b/devtools/shared/loader/moz.build new file mode 100644 index 0000000000..cda2083625 --- /dev/null +++ b/devtools/shared/loader/moz.build @@ -0,0 +1,21 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +# The browser-loader modules should only be shipped together with the client. +if CONFIG["MOZ_DEVTOOLS"] == "all": + DevToolsModules( + "browser-loader-mocks.js", + "browser-loader.js", + ) + +DevToolsModules( + "base-loader.sys.mjs", + "builtin-modules.js", + "DistinctSystemPrincipalLoader.sys.mjs", + "loader-plugin-raw.sys.mjs", + "Loader.sys.mjs", + "worker-loader.js", +) diff --git a/devtools/shared/loader/worker-loader.js b/devtools/shared/loader/worker-loader.js new file mode 100644 index 0000000000..4d8ff61bc7 --- /dev/null +++ b/devtools/shared/loader/worker-loader.js @@ -0,0 +1,536 @@ +/* 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"; + +/* global worker, DebuggerNotificationObserver */ + +// A CommonJS module loader that is designed to run inside a worker debugger. +// We can't simply use the SDK module loader, because it relies heavily on +// Components, which isn't available in workers. +// +// In principle, the standard instance of the worker loader should provide the +// same built-in modules as its devtools counterpart, so that both loaders are +// interchangable on the main thread, making them easier to test. +// +// On the worker thread, some of these modules, in particular those that rely on +// the use of Components, and for which the worker debugger doesn't provide an +// alternative API, will be replaced by vacuous objects. Consequently, they can +// still be required, but any attempts to use them will lead to an exception. +// +// Note: to see dump output when running inside the worker thread, you might +// need to enable the browser.dom.window.dump.enabled pref. + +this.EXPORTED_SYMBOLS = ["WorkerDebuggerLoader", "worker"]; + +// Some notes on module ids and URLs: +// +// An id is either a relative id or an absolute id. An id is relative if and +// only if it starts with a dot. An absolute id is a normalized id if and only +// if it contains no redundant components. +// +// Every normalized id is a URL. A URL is either an absolute URL or a relative +// URL. A URL is absolute if and only if it starts with a scheme name followed +// by a colon and 2 or 3 slashes. + +/** + * Convert the given relative id to an absolute id. + * + * @param String id + * The relative id to be resolved. + * @param String baseId + * The absolute base id to resolve the relative id against. + * + * @return String + * An absolute id + */ +function resolveId(id, baseId) { + return baseId + "/../" + id; +} + +/** + * Convert the given absolute id to a normalized id. + * + * @param String id + * The absolute id to be normalized. + * + * @return String + * A normalized id. + */ +function normalizeId(id) { + // An id consists of an optional root and a path. A root consists of either + // a scheme name followed by 2 or 3 slashes, or a single slash. Slashes in the + // root are not used as separators, so only normalize the path. + const [, root, path] = id.match(/^(\w+:\/\/\/?|\/)?(.*)/); + + const stack = []; + path.split("/").forEach(function (component) { + switch (component) { + case "": + case ".": + break; + case "..": + if (stack.length === 0) { + if (root !== undefined) { + throw new Error("Can't normalize absolute id '" + id + "'!"); + } else { + stack.push(".."); + } + } else if (stack[stack.length - 1] == "..") { + stack.push(".."); + } else { + stack.pop(); + } + break; + default: + stack.push(component); + break; + } + }); + + return (root ? root : "") + stack.join("/"); +} + +/** + * Create a module object with the given normalized id. + * + * @param String + * The normalized id of the module to be created. + * + * @return Object + * A module with the given id. + */ +function createModule(id) { + return Object.create(null, { + // CommonJS specifies the id property to be non-configurable and + // non-writable. + id: { + configurable: false, + enumerable: true, + value: id, + writable: false, + }, + + // CommonJS does not specify an exports property, so follow the NodeJS + // convention, which is to make it non-configurable and writable. + exports: { + configurable: false, + enumerable: true, + value: Object.create(null), + writable: true, + }, + }); +} + +/** + * Create a CommonJS loader with the following options: + * - createSandbox: + * A function that will be used to create sandboxes. It should take the name + * and prototype of the sandbox to be created, and return the newly created + * sandbox as result. This option is required. + * - globals: + * A map of names to built-in globals that will be exposed to every module. + * Defaults to the empty map. + * - loadSubScript: + * A function that will be used to load scripts in sandboxes. It should take + * the URL from and the sandbox in which the script is to be loaded, and not + * return a result. This option is required. + * - modules: + * A map from normalized ids to built-in modules that will be added to the + * module cache. Defaults to the empty map. + * - paths: + * A map of paths to base URLs that will be used to resolve relative URLs to + * absolute URLS. Defaults to the empty map. + * - resolve: + * A function that will be used to resolve relative ids to absolute ids. It + * should take the relative id of a module to be required and the absolute + * id of the requiring module as arguments, and return the absolute id of + * the module to be required as result. Defaults to resolveId above. + */ +function WorkerDebuggerLoader(options) { + /** + * Convert the given relative URL to an absolute URL, using the map of paths + * given below. + * + * @param String url + * The relative URL to be resolved. + * + * @return String + * An absolute URL. + */ + function resolveURL(url) { + let found = false; + for (const [path, baseURL] of paths) { + if (url.startsWith(path)) { + found = true; + url = url.replace(path, baseURL); + break; + } + } + if (!found) { + throw new Error("Can't resolve relative URL '" + url + "'!"); + } + + // If the url has no extension, use ".js" by default. + // Also allow loading JSMs, but they would need a shim in order to + // be loaded as a CommonJS module. (See SessionDataHelpers.jsm) + return url.endsWith(".js") || url.endsWith(".jsm") ? url : url + ".js"; + } + + /** + * Load the given module with the given url. + * + * @param Object module + * The module object to be loaded. + * @param String url + * The URL to load the module from. + */ + function loadModule(module, url) { + // CommonJS specifies 3 free variables: require, exports, and module. These + // must be exposed to every module, so define these as properties on the + // sandbox prototype. Additional built-in globals are exposed by making + // the map of built-in globals the prototype of the sandbox prototype. + const prototype = Object.create(globals); + prototype.Components = {}; + prototype.require = createRequire(module); + prototype.exports = module.exports; + prototype.module = module; + + const sandbox = createSandbox(url, prototype); + try { + loadSubScript(url, sandbox); + } catch (error) { + if (/^Error opening input stream/.test(String(error))) { + throw new Error( + "Can't load module '" + module.id + "' with url '" + url + "'!" + ); + } + throw error; + } + + // The value of exports may have been changed by the module script, so + // freeze it if and only if it is still an object. + if (typeof module.exports === "object" && module.exports !== null) { + Object.freeze(module.exports); + } + } + + /** + * Create a require function for the given module. If no module is given, + * create a require function for the top-level module instead. + * + * @param Object requirer + * The module for which the require function is to be created. + * + * @return Function + * A require function for the given module. + */ + function createRequire(requirer) { + return function require(id) { + // Make sure an id was passed. + if (id === undefined) { + throw new Error("Can't require module without id!"); + } + + // Built-in modules are cached by id rather than URL, so try to find the + // module to be required by id first. + let module = modules[id]; + if (module === undefined) { + // Failed to find the module to be required by id, so convert the id to + // a URL and try again. + + // If the id is relative, convert it to an absolute id. + if (id.startsWith(".")) { + if (requirer === undefined) { + throw new Error( + "Can't require top-level module with relative id " + + "'" + + id + + "'!" + ); + } + id = resolve(id, requirer.id); + } + + // Convert the absolute id to a normalized id. + id = normalizeId(id); + + // Convert the normalized id to a URL. + let url = id; + + // If the URL is relative, resolve it to an absolute URL. + if (url.match(/^\w+:\/\//) === null) { + url = resolveURL(id); + } + + // Try to find the module to be required by URL. + module = modules[url]; + if (module === undefined) { + // Failed to find the module to be required in the cache, so create + // a new module, load it from the given URL, and add it to the cache. + + // Add modules to the cache early so that any recursive calls to + // require for the same module will return the partially-loaded module + // from the cache instead of triggering a new load. + module = modules[url] = createModule(id); + + try { + loadModule(module, url); + } catch (error) { + // If the module failed to load, remove it from the cache so that + // subsequent calls to require for the same module will trigger a + // new load, instead of returning a partially-loaded module from + // the cache. + delete modules[url]; + throw error; + } + + Object.freeze(module); + } + } + + return module.exports; + }; + } + + const createSandbox = options.createSandbox; + const globals = options.globals || Object.create(null); + const loadSubScript = options.loadSubScript; + + // Create the module cache, by converting each entry in the map from + // normalized ids to built-in modules to a module object, with the exports + // property of each module set to a frozen version of the original entry. + const modules = options.modules || {}; + for (const id in modules) { + const module = createModule(id); + module.exports = Object.freeze(modules[id]); + modules[id] = module; + } + + // Convert the map of paths to base URLs into an array for use by resolveURL. + // The array is sorted from longest to shortest path to ensure that the + // longest path is always the first to be found. + let paths = options.paths || Object.create(null); + paths = Object.keys(paths) + .sort((a, b) => b.length - a.length) + .map(path => [path, paths[path]]); + + const resolve = options.resolve || resolveId; + + this.require = createRequire(); +} + +this.WorkerDebuggerLoader = WorkerDebuggerLoader; + +var loader = { + lazyGetter(object, name, lambda) { + Object.defineProperty(object, name, { + get() { + delete object[name]; + object[name] = lambda.apply(object); + return object[name]; + }, + configurable: true, + enumerable: true, + }); + }, + lazyServiceGetter() { + throw new Error("Can't import XPCOM service from worker thread!"); + }, + lazyRequireGetter(obj, properties, module, destructure) { + if (Array.isArray(properties) && !destructure) { + throw new Error( + "Pass destructure=true to call lazyRequireGetter with an array of properties" + ); + } + + if (!Array.isArray(properties)) { + properties = [properties]; + } + + for (const property of properties) { + Object.defineProperty(obj, property, { + get: () => + destructure + ? worker.require(module)[property] + : worker.require(module || property), + }); + } + }, +}; + +// The following APIs are defined differently depending on whether we are on the +// main thread or a worker thread. On the main thread, we use the Components +// object to implement them. On worker threads, we use the APIs provided by +// the worker debugger. + +/* eslint-disable no-shadow */ +var { + Debugger, + URL, + createSandbox, + dump, + rpc, + loadSubScript, + setImmediate, + xpcInspector, +} = function () { + // Main thread + if (typeof Components === "object") { + const principal = Components.Constructor( + "@mozilla.org/systemprincipal;1", + "nsIPrincipal" + )(); + + // To ensure that the this passed to addDebuggerToGlobal is a global, the + // Debugger object needs to be defined in a sandbox. + const sandbox = Cu.Sandbox(principal, { + wantGlobalProperties: ["ChromeUtils"], + }); + Cu.evalInSandbox( + ` +const { addDebuggerToGlobal } = ChromeUtils.importESModule( + 'resource://gre/modules/jsdebugger.sys.mjs' +); +addDebuggerToGlobal(globalThis); +`, + sandbox + ); + const Debugger = sandbox.Debugger; + + const createSandbox = function (name, prototype) { + return Cu.Sandbox(principal, { + invisibleToDebugger: true, + sandboxName: name, + sandboxPrototype: prototype, + wantComponents: false, + wantXrays: false, + }); + }; + + const rpc = undefined; + + // eslint-disable-next-line mozilla/use-services + const subScriptLoader = Cc[ + "@mozilla.org/moz/jssubscript-loader;1" + ].getService(Ci.mozIJSSubScriptLoader); + + const loadSubScript = function (url, sandbox) { + subScriptLoader.loadSubScript(url, sandbox); + }; + + const Timer = ChromeUtils.importESModule( + "resource://gre/modules/Timer.sys.mjs" + ); + + const setImmediate = function (callback) { + Timer.setTimeout(callback, 0); + }; + + const xpcInspector = Cc["@mozilla.org/jsinspector;1"].getService( + Ci.nsIJSInspector + ); + + const { URL } = Cu.Sandbox(principal, { + wantGlobalProperties: ["URL"], + }); + + return { + Debugger, + URL, + createSandbox, + dump: this.dump, + rpc, + loadSubScript, + setImmediate, + xpcInspector, + }; + } + // Worker thread + const requestors = []; + + const scope = this; + + const xpcInspector = { + get eventLoopNestLevel() { + return requestors.length; + }, + + get lastNestRequestor() { + return requestors.length === 0 ? null : requestors[requestors.length - 1]; + }, + + enterNestedEventLoop(requestor) { + requestors.push(requestor); + scope.enterEventLoop(); + return requestors.length; + }, + + exitNestedEventLoop() { + requestors.pop(); + scope.leaveEventLoop(); + return requestors.length; + }, + }; + + return { + Debugger: this.Debugger, + URL: this.URL, + createSandbox: this.createSandbox, + dump: this.dump, + rpc: this.rpc, + loadSubScript: this.loadSubScript, + setImmediate: this.setImmediate, + xpcInspector, + }; +}.call(this); +/* eslint-enable no-shadow */ + +// Create the default instance of the worker loader, using the APIs we defined +// above. + +this.worker = new WorkerDebuggerLoader({ + createSandbox, + globals: { + isWorker: true, + dump, + loader, + rpc, + URL, + setImmediate, + retrieveConsoleEvents: this.retrieveConsoleEvents, + setConsoleEventHandler: this.setConsoleEventHandler, + clearConsoleEvents: this.clearConsoleEvents, + console, + btoa: this.btoa, + atob: this.atob, + Services: Object.create(null), + ChromeUtils, + DebuggerNotificationObserver, + + // The following APIs rely on the use of Components, and the worker debugger + // does not provide alternative definitions for them. Consequently, they are + // stubbed out both on the main thread and worker threads. + Cc: undefined, + ChromeWorker: undefined, + Ci: undefined, + Cu: undefined, + Cr: undefined, + Components: undefined, + }, + loadSubScript, + modules: { + Debugger, + xpcInspector, + }, + paths: { + // ⚠ DISCUSSION ON DEV-DEVELOPER-TOOLS REQUIRED BEFORE MODIFYING ⚠ + devtools: "resource://devtools", + // ⚠ DISCUSSION ON DEV-DEVELOPER-TOOLS REQUIRED BEFORE MODIFYING ⚠ + promise: "resource://gre/modules/Promise-backend.js", + // ⚠ DISCUSSION ON DEV-DEVELOPER-TOOLS REQUIRED BEFORE MODIFYING ⚠ + "xpcshell-test": "resource://test", + // ⚠ DISCUSSION ON DEV-DEVELOPER-TOOLS REQUIRED BEFORE MODIFYING ⚠ + }, +}); |