mimicry/browser.js

713 lines
26 KiB
JavaScript

"use strict";
const Mimicry = (function privateMimicryClosure () {
class Mimicry {
/**
* Creates an instance of the Mimicry.
*
* @param {Object} [config] - configuration of the Mimicry.
* @param {Number} [config.maxParallelLoadings] - amount of files that are loaded in parallel.
* @param {String} [config.baseUrl] - URL prefix that will be added to every.
* @param {String} [config.seed] - request optional component allowing to drop user cached files.
*/
constructor (config) {
instances.push(this);
this._queue = null; //deferred Queue
this._loadedFiles = new Set();
this._config = Object.create(null);
this._loadingFiles = new Map();
this._plannedFiles = Object.create(null);
this._modules = new Map();
this._typePaths = new Map();
this._loadedAssets = new Map();
this._libs = new Map();
this._errors = new Map();
this._moduleErrors = [];
this._errorHandlers = [];
this._progressHandlers = [];
Object.assign(this._config, defaultOptions, config);
this._baseUrl = this._config.baseUrl;
if (this._baseUrl && !this._baseUrl.endsWith("/"))
this._baseUrl += "/";
this._globalErrorHandler = onGlobalError.bind(this);
window.addEventListener("error", this._globalErrorHandler);
if (ready)
createDeferredParts.call(this);
else
this._deferred = [];
}
/**
* Finalizes Mimicry, clears as much memory as it can, forgets all the handlers and contexts
* */
destructor () {
window.off("error", this._globalErrorHandler);
const index = instances.indexOf(this);
instances.splice(index, 1);
delete this._queue;
delete this._loadedFiles;
delete this._config;
delete this._loadingFiles;
delete this._plannedFiles;
delete this._modules;
delete this._typePaths;
delete this._loadedAssets;
delete this._libs;
delete this._errors;
delete this._moduleErrors;
delete this._errorHandlers;
delete this._progressHandlers;
delete this._globalErrorHandler;
delete this._deferred;
delete this._baseUrl;
this.destroyed = true;
}
/**
* Loads a file, calls the callback if it has loaded successfully,
* calls error callback if there was any sort of error
*
* @param {String} path - path to the module
* @param {Function} [callback=contentType] - a callback that is called after module is loaded
* @param {Function} [error=contentType] - a callback that is called if there was an error
* @param {Mimicry.Type} [contentType=Mimicry.Type.js] - type of the content to load
*/
load (path, callback, error, contentType) {
if (!ready) {
this._deferred.push(["load", path, callback, error, contentType]);
return;
}
if (!(error instanceof Function) && contentType === undefined) {
contentType = error;
error = undefined;
}
if (!(callback instanceof Function) && contentType === undefined) {
contentType = callback;
callback = undefined;
}
if (!contentType)
contentType = Type.js;
scheduleLoad.call(this,
Dependency.refinePath(path, contentType, this._baseUrl, this._typePaths, this._libs),
callback, error, contentType, path);
}
/**
* Declares a module.
* The mainstream way to use Mimicry is to declare modules.
* It's best If you declare one module per file.
*
* The best way to export anything from the module is to return it from the callback
*
* @param {Array<String>} dependencies - list of module names that this module depends on
* @param {Function} [callback] - module body that will be executed as soon all dependencies are resolved
* @param {HTMLScriptElement} [script] - script element launching this module (you don't need to pass it, it's automatic)
*/
module (dependencies, callback, script) {
if (!ready) {
this._deferred.push(["module", dependencies, callback, document.currentScript]);
return;
}
const href = getCurrentHref(script);
if (!href.length)
throw new Error("Couldn't understand module path");
const moduleName = Dependency.nameFromHref(href, this._baseUrl, this._typePaths, this._libs);
if (this._modules.has(moduleName))
throw new Error("An attempt to declare more than one module per file (" + moduleName + ")");
const {deps, body} = Dependency.refineModuleArguments(dependencies, callback);
const module = new Module(deps, body);
this._modules.set(moduleName, module);
for (let i = 0; i < deps.length; ++i) {
const element = deps[i];
const contentType = element.contentType;
const elName = element.name = fromRelative(element.name, moduleName);
this.load(elName, loadedFileForModule.bind(this, module, elName), contentType);
}
if (module.allDependenciesResolved)
module.execute(this, window);
}
/**
* Synchronously returns the module value if it was
* already loaded and executed, returns undefined otherwise
* */
getModule(/*String*/moduleName) {
const module = this._modules.get(moduleName);
if (module && module.executed)
return module.value;
}
/**
* Synchronously returns the asses if it was already loaded, returns undefined otherwise
* */
getAsset(/*String*/assetName) {
const asset = this._loadedAssets.get(assetName);
if (asset)
return asset;
}
/**
* If you wish to keep all files of some types in a designated folder of your project
* you can declare it using this function.
*
* Say, you want to have all JSON files in "json" folder, and all CSS files in "styles/css" folder.
* After calling "mimicry.setTypePath(Mimicry.Type.json, 'json')" and
* "mimicry.setTypePath(Mimicry.Type.css, 'styles/css')" you can access files from those folders
* in a following manner
*
* "mimicry.load('json/config/main', () => {}, Mimicry.Type.json)" changes to
* "mimicry.load('config/main', () => {}, Mimicry.Type.json)", and
*
* "mimicry.load('styles/css/basic', () => {}, Mimicry.Type.css)" to
* "mimicry.load('basic', () => {}, Mimicry.Type.css)"
* */
setTypePath (/*Type*/type, /*String*/path) {
if (path && !path.endsWith("/"))
path += "/";
this._typePaths.set(type, path);
}
/**
* You might want to include other projects in your project. I will call those imported subprojects libraries
* In mimicry there is a way to hide actual path behind library name.
*
* Say you included a library called "flame", and placed in "libs/external/flame".
* After calling "mimicry.setLibPath('flame', 'libs/external/flame')" you can access files from those libraries
* in a following manner
*
* "mimicry.load('libs/external/flame/index', () => {})" changes to
* "mimicry.load('@flame/index', () => {})"
* */
setLibPath (/*String*/libraryName, /*String*/libraryPath) {
const path = this._libs.get(libraryName);
if (path && path !== libraryPath)
throw new Error("Mimicry error: library " + libraryName + " already had a path " + path +
", assigning new path " + libraryPath + " would probably break everything");
this._libs.set(libraryName, libraryPath);
}
/**
* Adds a handler for you to handle errors that this instance of mimicry
* faces loading and evaluating files.
*
* These handlers get called after (if there was any) error callbacks passed to load operations.
*
* @param {Function} handler - a handler that would be called in case of error
*
* Handler is called with a single argument of error, which can one of the following types:
* - Mimicry.FileError - if there was an error on file evaluation
* - Mimicry.LoadError - if mimicry could not load a file
* */
addErrorHandler (handler) {
this._errorHandlers.push(handler);
}
/**
* Method provides you information about the progress of your loads.
* Sometimes when you load lots of files in bulk you wish to show a progress indicator.
*
* @param {Function} handler - a handler which is called when the progress changes
* Passed handler is called with following arguments:
* {Number} loaded - how many files mimicry have loaded so far
* {Number} pending - how many files are currently loading or in a queue
* {Number} total - how many files does mimicry even know about
* */
addProgressHandler (handler) {
this._progressHandlers.push(handler);
}
/**
* If you use Mimicry in a script file included right after mimicry
* it might not be able to work yet: it doesn't yet have some classes for normal work.
* In this "unready" state you can not
* - access Mimicry.FileError nad Mimicry.LoadError classes
* - access Mimicry.Type enum.
*
* You can, however, perform default (meaning Mimicry.type.js) loads, declare modules,
* set up paths, and subscribe to handlers.
*
* @returns {Boolean} - if the instance is ready to be used
* */
get ready () {return ready;}
/**
* @returns {Number} - amount of files that are currently loading or in a queue
* */
get pendingFilesAmount () {return this._loadingFiles.size + this._queue.length;}
/**
* @returns {Number} - amount of files mimicry have loaded until this moment
* */
get loadedFilesAmount () {return this._loadedFiles.size;}
/**
* @returns {Object} - default mimicry options object
* */
static get defaultOptions () {return defaultOptions;}
/**
* If you use Mimicry in a script file included right after mimicry
* it might not be able to work yet: it doesn't yet have some classes for normal work.
* In this "unready" state you can not
* - access Mimicry.FileError nad Mimicry.LoadError classes
* - access Mimicry.Type enum.
*
* You can, however, perform default (meaning Mimicry.type.js) loads, declare modules,
* set up paths, and subscribe to handlers.
*
* This state is on the static level: once all necessary files are loaded all mimicry instances are
* ready and don't need to be waited on
*
* @returns {Boolean} - if the Mimicry system is ready to be used
* */
static get ready () {return ready;}
/**
* Normally mimicry loads .js files, but you can also load other stuff like json, or css, or binary
* I expect you to pass values from this enum to specify what exactly you are loading if it is not a
* JavaScript file
*
* @returns {Mimicry.Type} - type of content
* */
static get Type () {return Type;}
static get FileError() {return FileError;}
static get LoadError() {return LoadError;}
}
const defaultOptions = {
maxParallelLoadings: 10,
baseUrl: "",
seed: ""
};
Object.freeze(defaultOptions)
function createDeferredParts () {
this._queue = new Queue();
}
function launchDeferredRequests () {
if (this._deferred instanceof Array) {
while (this._deferred.length) {
const args = this._deferred.shift();
const methodName = args.shift();
this[methodName].apply(this, args);
}
delete this._deferred;
}
}
function callCallback(/*Function*/callback, /*String*/originalName) {
if (callback instanceof Function) {
const module = this._modules.get(originalName);
if (module) {
module.addCallback(callback, window);
} else {
const asset = this._loadedAssets.get(originalName);
if (asset)
callback(asset);
else
callback(window);
}
}
}
/**
* @param {String} path
* @param {Function} callback
* @param {Function} error
* @param {Type} contentType
* @param {String} originalName
* */
function scheduleLoad (path, callback, error, contentType, originalName) {
if (this._loadedFiles.has(path))
return callCallback.call(this, callback, originalName);
let loadingFile = this._loadingFiles.get(path);
if (loadingFile === undefined)
loadingFile = this._plannedFiles[path];
if (loadingFile) {
Dependency.checkContentType(loadingFile.contentType, contentType, path);
loadingFile.addSuccessHandler(callback);
loadingFile.addErrorHandler(error);
return;
}
loadingFile = new LoadingFile(contentType, originalName, callback, error);
if (this._loadingFiles.size < this._config.maxParallelLoadings) {
executeLoad.call(this, path, loadingFile);
} else {
this._queue.push(path);
this._plannedFiles[path] = loadingFile;
}
notifyProgressSubscribers.call(this);
}
function executeLoad (/*String*/path, /*LoadingFile*/loadingFile) {
this._loadingFiles.set(path, loadingFile);
const boundLoad = onLoaded.bind(this, path);
const boundError = onError.bind(this, path);
switch (loadingFile.contentType) {
case Type.js:
getScriptWithTag(path, boundLoad, boundError, this._config.seed);
break;
case Type.css:
getStylesheetWithTag(path, boundLoad, boundError, this._config.seed);
break;
case Type.json:
getByXHR(path, boundLoad, boundError, "json", this._config.seed);
break;
case Type.binary:
getByXHR(path, boundLoad, boundError, "arraybuffer", this._config.seed);
break;
case Type.text:
getByXHR(path, boundLoad, boundError, "text", this._config.seed);
break;
}
}
function notifyProgressSubscribers() {
const pending = this.pendingFilesAmount;
const loaded = this.loadedFilesAmount;
for (let i = 0; i < this._progressHandlers.length; ++i) {
const handler = this._progressHandlers[i];
handler(loaded, pending, loaded + pending);
}
}
function onLoaded(/*String*/path, /*HTMLScriptElement*/event) {
if (this._moduleErrors.length)
return onModuleError.call(this, path, event);
if (this._errors.has(path))
return onError.call(this, path, event);
const loading = this._loadingFiles.get(path);
this._loadedFiles.add(path);
switch (loading.contentType) {
case Type.json:
case Type.binary:
case Type.text:
this._loadedAssets.set(loading.originalName, event.target.response);
break;
}
loading.callSuccessHandlers(callCallback, this);
cleanUp.call(this, path, event);
checkNextElement.call(this);
notifyProgressSubscribers.call(this);
}
function onError (/*String*/path, /*HTMLScriptElement*/element) {
const loading = this._loadingFiles.get(path);
let error = this._errors.get(path);
if (!(error instanceof BasicError))
error = new LoadError(path, loading.originalName);
loading.callErrorHandlers(error);
for (let i = 0; i < this._errorHandlers.length; ++i)
this._errorHandlers[i](error);
cleanUp.call(this, path, element);
checkNextElement.call(this);
notifyProgressSubscribers.call(this);
}
function onModuleError (/*String*/path, /*HTMLScriptElement*/element) {
for (let i = 0; i < this._moduleErrors.length; ++i) {
const error = this._moduleErrors[i];
error.path = path;
for (let i = 0; i < this._errorHandlers.length; ++i)
this._errorHandlers[i](error)
}
this._moduleErrors = [];
cleanUp.call(this, path, element);
checkNextElement.call(this);
notifyProgressSubscribers.call(this);
}
function cleanUp (/*String*/path, /*Event*/element) {
const loading = this._loadingFiles.get(path);
this._loadingFiles.delete(path);
this._errors.delete(path);
switch (loading.contentType) {
case Type.js:
cleanTag(element.target);
break;
case Type.css:
cleanUpHandlers(element.target);
break;
case Type.json:
case Type.binary:
case Type.text:
cleanUpHandlers(element);
break
}
loading.destructor();
}
function cleanTag (/*HTMLElement or Event*/tag) {
tag.remove();
cleanUpHandlers(tag);
}
function cleanUpHandlers (/*HTMLElement*/tag) {
delete tag.onload;
delete tag.onerror;
}
function onGlobalError (event, url, line, col, errorObj) {
let message;
if (event instanceof ErrorEvent) {
url = event.filename;
line = event.lineno;
col = event.colno;
errorObj = event.error;
message = event.message;
} else {
message = event;
}
if (url && url.indexOf(location.origin) === 0)
url = url.slice(location.origin.length);
if (!setFileError(url, message, line, col, errorObj)) {
let href = getCurrentHref();
const index = url.lastIndexOf(href);
if (href.length && index === url.length - href.length)
setFileError(href, message, line, col, errorObj)
}
}
function setFileError(name, message, line, col, errorObj) {
let result = true;
let loading = this._loadingFiles.get(name);
if (loading instanceof LoadingFile)
this._errors.set(name, new FileError(name, loading.originalName, message, line, col, errorObj));
else
result = false;
return result;
}
function checkNextElement () {
while (!this._queue.empty && (this._loadingFiles.size < this._config.maxParallelLoadings)) {
const path = this._queue.pop();
const loadingFile = this._plannedFiles[path];
delete this._plannedFiles[path];
executeLoad.call(this, path, loadingFile);
}
}
/**
* @param {String} path - path of the file you wish to load
* @param {Function} [callback] - handler that would be called upon successful load
* @param {Function} [error] - handler that would ba called upon error
* @param {String} [seed] - a short seed to avoid unwanted caching
* */
function getScriptWithTag (path, callback, error, seed) {
const tag = document.createElement("script");
tag.setAttribute("async", "");
document.head.appendChild(tag);
tag.onload = callback;
tag.onerror = error;
if (seed)
path += "?seed=" + seed;
tag.src = path;
}
/**
* @param {String} path - path of the file you wish to load
* @param {Function} [callback] - handler that would be called upon successful load
* @param {Function} [error] - handler that would ba called upon error
* @param {String} [seed] - a short seed to avoid unwanted caching
* */
function getStylesheetWithTag (path, callback, error, seed) {
const tag = document.createElement("link");
tag.setAttribute("type", "text/css");
tag.setAttribute("rel", "stylesheet");
document.head.appendChild(tag);
tag.onload = callback;
tag.onerror = error;
if (seed)
path += "?seed=" + seed;
tag.href = path;
}
/**
* @param {String} url
* @param {Function} callback
* @param {Function} error
* @param {XMLHttpRequestResponseType} [responseType="arraybuffer"]
* @param {String} [seed=""]
* */
function getByXHR (url, callback, error, responseType="arraybuffer", seed="") {
const xhr = new XMLHttpRequest();
if (seed)
url += "?seed=" + seed;
xhr.open("GET", url, true);
xhr.responseType = responseType;
xhr.onload = callback.bind(xhr);
xhr.onerror = error.bind(xhr);
xhr.send();
}
function getCurrentHref(src) {
let possible = document.currentScript;
if (src instanceof HTMLScriptElement)
possible = src;
const script = possible || launcher;
if (script instanceof HTMLScriptElement)
return script.attributes.src.value;
else
return "";
}
function loadedFileForModule (/*Module*/requester, /*String*/fileName) {
const dep = this._modules.get(fileName);
if (dep)
dep.addCallback(requester.incrementDependency.bind(requester, this, window), window);
else
requester.incrementDependency(this, window);
}
const launcher = document.currentScript;
const instances = [];
let BasicError, LoadError, FileError;
let LoadingFile;
let Queue;
let Dependency, Type;
let Module;
let ready = false;
let errorsReady = false;
let fileReady = false;
let queueReady = false;
let dependencyReady = false;
let moduleReady = false;
function checkDeferred () {
if (!ready) {
if (errorsReady && fileReady && queueReady && dependencyReady && moduleReady) {
ready = true;
for (let i = 0; i < instances.length; ++i) {
const instance = instances[i];
createDeferredParts.call(instance);
launchDeferredRequests.call(instance);
}
if (launcher instanceof HTMLScriptElement) {
const main = launcher.dataset.main
if (main)
getScriptWithTag(main);
}
}
}
}
let pathPrefix = directory(launcher.src);
if (pathPrefix !== "")
pathPrefix += "/";
function directory (path) {
const hops = path.split("/");
hops.pop();
return hops.join("/");
}
function fromRelative (path, reference) {
let indexSame = path.indexOf("./");
let indexParent = path.indexOf("../");
if (indexSame === 0 || indexParent === 0) {
reference = directory(reference);
while (indexSame === 0 || indexParent === 0) {
if (indexSame === 0) {
path = path.slice(2);
} else {
reference = directory(reference);
path = path.slice(3);
}
indexSame = path.indexOf("./");
indexParent = path.indexOf("../");
}
let result = reference;
if (result !== "")
result += "/";
result += path
return result
}
return path;
}
getScriptWithTag(pathPrefix + "errors.js", function (event) {
cleanTag(event.target);
BasicError = window.__MimicryErrors__.BasicError;
LoadError = window.__MimicryErrors__.LoadError;
FileError = window.__MimicryErrors__.FileError;
delete window.__MimicryErrors__;
errorsReady = true;
checkDeferred();
});
getScriptWithTag(pathPrefix + "loadingFile.js", function (event) {
cleanTag(event.target);
LoadingFile = window.__MimicryLoadingFile__;
delete window.__MimicryLoadingFile__;
fileReady = true;
checkDeferred();
});
getScriptWithTag(pathPrefix + "queue.js", function (event) {
cleanTag(event.target);
Queue = window.__MimicryQueue__;
delete window.__MimicryQueue__;
queueReady = true;
checkDeferred();
});
getScriptWithTag(pathPrefix + "dependency.js", function (event) {
cleanTag(event.target);
Dependency = window.__MimicryDependency__;
Type = Dependency.Type;
delete window.__MimicryDependency__;
dependencyReady = true;
checkDeferred();
});
getScriptWithTag(pathPrefix + "module.js", function (event) {
cleanTag(event.target);
Module = window.__MimicryModule__;
delete window.__MimicryModule__;
moduleReady = true;
checkDeferred();
});
return Mimicry;
})();