Prerequisite Knowledge
Understand the core systems about the viewer.
Introduction - Profiles
All the source code for the viewer can be found in path_to_viewer/resources/app
.
In the majority of cases this code is not relevant, and we are only interested in the profiles which the viewer loads and uses for parsing and rendering replays. These can be found in path_to_viewer/resources/app/assets/profiles
where each folder here represents a separate profile.
Out of the box you should find 4 profiles:
vanilla
- This is the base profile that gets automatically loaded and is used for handling vanilla gameplay.template
- A template profile which can be copied and pasted for creating new profiles. By default loads vanilla.duo trials
- Profile for the custom rundown Duo Trials.descent
- Profile for the custom rundown Descent.
When a profile is loaded, all the JavaScript files within the profile folder is executed and will be watched for hot reloading via a system called the Async Script Loader (ASL):
Async Script Loader (ASL)
ASL is the core system that handles the dynamic loading of scripts, it is used over the existing JavaScript module system, ES6 Modules, to support hot reloading for the ease of use for developers.
Since it does not rely on ES6 Modules, it has its own syntax for performing corresponding actions. Lets take a look at a simple ASL module and its ES6 Modules counterpart:
const { Vector3 } = await require("three", "esm");
const { foo } = await require("./B.js", "asl");
exports.bar = function() {
return foo(new Vector3(1, 2, 3));
}
The ES6 Modules counterpart:
import { Vector3 } from "three";
import { foo } from "./B.js";
export function bar() {
return foo(new Vector3(1, 2, 3));
}
As you can see there are not many differences between ES6 Modules and ASL apart from the syntax.
Async
ASL modules all execute in an Asynchronous context. Under the hood, this is done via an eval()
call within an async function
:
(async function() { /* Your Module Code ... */ })()
This allows for the use of the await
keyword to be used which is useful with async methods that fetch content such as models / other ASL modules.
Importing Modules
To import other modules you use the function require
:
function require(path: string, type: "esm" | "asl" = "asl"): Promise<any>;
require
accepts a path to the module you wish to import and an optional parameter to specify the import type. If no import type is specified, "asl"
is assumed by default.
If the path
provided is an absolute path (does not start with .
) then it will load the path relative to the baseURI
of the page. This will be from path_to_viewer/resources/app/assets/main
.
For example, the following code will import the module located in path_to_viewer/resources/app/assets/main/B.js
:
require("B.js");
Relative paths are resolved as expected, importing the module relative ot the current module.
Sometimes you may wish to import regular ES6 Modules and not just ASL modules, this is where the type
parameter comes in. By default, it is assumed that you are loading an ASL module, however if you specify "esm"
, you can import regular ES6 modules.
Importing ES6 modules follows the same path resolution as if you were using the import
statement. This includes aliases specified by the importmap
.
For example, the viewer frequently uses the THREE JS library which is an ES6 module:
const { Vector3 } = await require("three", "esm");
require
returns a Promise which resolves once a module finishes importing. Once resolved, it returns an object containing the exports of the imported module.
Because it returns a Promise, and modules execute within an Async context, you can await
a require
call:
const B = await require("./B.js");
Exports
ASL supports exporting items from one module such that they are accessible from another. This is done using the exports
syntax:
// Module A
exports.foo = function() {
console.log("Hello from A");
}
exports.bar = 1;
// Module B
const { foo, bar } = await require("./A.js");
foo(); // "Hello from A"
console.log(bar); // 1
Unlike ES6 module exports, features such as "default exports" or "aggregation" are not supported. ASL exports are more akin to using the this
keyword to assign properties to an instance.
Because of this, care needs to be taken to ensure the correct behaviour when changing non-referenced values:
// Module A
let foo = 1;
exports.foo = foo;
foo = 2;
// As foo is not an object, it gets copied and thus changing foo
// does not change exports.foo
exports.bar = 1;
exports.bar = 2;
// Module B
const { foo, bar } = await require("./A.js");
console.log(foo); // 1
console.log(bar); // 2
Further more, exports are immutable. The decision to do so is to prevent reference gymnastics with import syntax:
let { foo } = await require("./A.js");
foo = 10; // This does not actually change the value of `foo` in module `A`,
// due to how javascript object decomposition works.
const A = await require("./A.js");
A.foo = 10; // Although this would work, the syntax is a gotcha that is annoying
// to account for when mixed with decomposition syntax and is just
// a source of bugs in the event you forget about this behaviour.
//
// As a result, I don't allow this behaviour at all and ASL
// will throw an Error.
The correct pattern to handle the above would be:
// ** Module A **
// object reference pattern
exports.foo = { value: 10 };
// getter / setter pattern
let bar = 10;
exports.bar = (...args) => {
if (args.length > 0) bar = args[0];
else return bar;
}
// ** Module B **
const { foo, bar } = await require("./A.js");
console.log(foo.value); // 10
foo.value = 20;
console.log(foo.value); // 20
console.log(bar()); // 10
bar(20);
console.log(bar()); // 20
Circular Dependencies
ES6 will still load modules even if there are circular dependencies which can cause bugs due to an export not yet being available within said loop.
ASL, on the other hand, simply will continue execution until a module has fully loaded. This means that in the event of a circular dependency, the module will not complete execution:
// Module A
exports.A = 1;
console.log("waiting for B"); // "waiting for B"
const { B } = await require("./B.js");
console.log(exports.A + B); // does not get reached
// Module B
exports.B = 2;
console.log("waiting for A"); // "waiting for A"
const { A } = await require("./A.js");
console.log(A + exports.B); // does not get reached
In the above case, both modules will pause on there await require()
after logging waiting for A
and waiting for B
. This is because require
cannot import either module until both have completed, but as both depend on each other, this will never happen.
To get around this, ASL provides a the method module.ready()
which tells ASL that all exports are finished and the module is ready for use even though it has not yet finished executing.
// Module A
exports.A = 1;
module.ready();
console.log("waiting for B"); // "waiting for B"
const { B } = await require("./B.js");
console.log(exports.A + B); // 3
// Module B
exports.B = 2;
module.ready();
console.log("waiting for A"); // "waiting for A"
const { A } = await require("./A.js");
console.log(A + exports.B); // 3
Once a module has been marked as ready
it's exports can no longer be changed:
exports.A = 1;
module.ready();
exports.A = 2; // Error! - Immutable exports
exports.B = 2; // Error! - Cannot add new exports
Circular dependencies can be debugged in the electron browser developer tools and typing vm.requires
. This will output a Map
where the keys represent modules that are waiting and the values specify which modules they are waiting for.
Destructor
As ASL provides hot reloading support, modules need to manage being unloaded to free resources and prevent memory leaks. To do this, you can assign a destructor which gets called when a module is unloaded:
module.destructor = function() {
console.log("This module is being destructed!");
};
Error Handling
ASL tries to provide as helpful error messages as possibly:

However, sometimes this is not possible as the error is not caught by ASL such as in the case of Promises. In these cases, for better error handling, you can catch the error and throw it again throw module.raise()
:
promise.catch((error) => throw module.error(error));
This will wrap the error in a verbose message as shown above.
You may also include an additional message to the error to provide more context, this will be logged along side the actual error:
promise.catch((error) => throw module.error(error, "Context"));
Wrapping an error multiple times will not do anything, and thus trying to add multiple context messages will only result in the first being visible:
throw module.error(module.error(error, "1"), "2");
The above will only log "1"
and "2"
will never be visible.
Memory Leaks
Due to the nature of hot reloading and switching profiles, it can be very easy to leak memory.
JavaScript is a garbage collected language which means that memory is freed once all references to objects are lost. The issue is that there are a lot of hidden references that are held by the browser which can stop modules from being properly garbage collected after hot reloading / changing profile.
let A = 0;
exports.A = () => A;
const element = document.getElementById("some-element");
element.addEventListener("click", () => {
++A;
element.innerHTML = `${exports.A()}`;
});
The above module will leak as it can never be garbage collected since the element
on the browser will continue to hold a reference to it via the event listener.
A simple fix can be achieved by clearing the event listener on module destruction.
let A = 0;
exports.A = () => A;
const element = document.getElementById("some-element");
const onClick = () => {
++A;
element.innerHTML = `${exports.A()}`;
};
element.addEventListener("click", onClick);
module.destructor = () => {
element.removeEventListener("click", onClick);
};
A useful pattern for this is the AbortController pattern which allows you to pass an abort signal to each event listener such that it automatically is cleaned on destruction:
const dispose = new AbortController();
module.destructor = () => {
dispose.abort();
};
let A = 0;
exports.A = () => A;
const element = document.getElementById("some-element");
element.addEventListener("click", () => {
++A;
element.innerHTML = `${exports.A()}`;
}, { signal: dispose.signal });
To simply the above, ASL provides the abort signal for you in module.dispose
such that you do not need to create one yourself:
let A = 0;
exports.A = () => A;
const element = document.getElementById("some-element");
element.addEventListener("click", () => {
++A;
element.innerHTML = `${exports.A()}`;
}, { signal: module.dispose }); // module.dispose signal is triggered after
// module destructor automatically
Despite this, the use of AbortController is still useful to be aware of in the event you want to dispose of event listeners manually.
Additional Helpers
ASL provides additional helper functions:
module.src
- provides the path to the current module file.module.rel(path: string)
- converts a relative path from this module to an absolute path relative to the baseURI. This is useful when targeting relative files forfetch
or model loaders such asGLTFLoader
.
Last updated