summaryrefslogtreecommitdiffstats
path: root/devtools/shared/loader/base-loader.sys.mjs
blob: ab005b81e292d4097706e325fe055c2be1b43c39 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
/* 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.defineESModuleGetters(
  lazy,
  {
    NetUtil: "resource://gre/modules/NetUtil.sys.mjs",
  },
  { global: "contextual" }
);

// 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",
      "CustomStateSet",
      "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, {
        global: "contextual",
      });
    } 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;
}