diff options
Diffstat (limited to '')
-rw-r--r-- | devtools/shared/loader/base-loader.sys.mjs | 640 |
1 files changed, 640 insertions, 0 deletions
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; +} |