From 9b04a90b5388ade4392a30d06819491855167cdc Mon Sep 17 00:00:00 2001 From: blue Date: Sat, 9 Sep 2023 10:22:19 -0300 Subject: [PATCH] first ideas for nodejs implementation --- browser.js | 24 +- dependency.js | 2 +- module.js | 17 +- node.js | 531 +++++++++++++++++++++++++++++++++++++++++++ test/index.html | 2 +- test/index.js | 14 +- test/index_main.html | 2 +- test/main.js | 44 ++++ 8 files changed, 610 insertions(+), 26 deletions(-) create mode 100644 node.js create mode 100644 test/main.js diff --git a/browser.js b/browser.js index 290f319..8a521a4 100644 --- a/browser.js +++ b/browser.js @@ -110,15 +110,15 @@ const Mimicry = (function privateMimicryClosure () { * * @param {Array} 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) { - console.log(document.currentScript); + module (dependencies, callback, script) { if (!ready) { - this._deferred.push(["module", dependencies, callback]); + this._deferred.push(["module", dependencies, callback, document.currentScript]); return; } - const href = getCurrentHref(); + const href = getCurrentHref(script); if (!href.length) throw new Error("Couldn't understand module path"); @@ -139,7 +139,7 @@ const Mimicry = (function privateMimicryClosure () { } if (module.allDependenciesResolved) - module.execute(this); + module.execute(this, window); } /** @@ -316,7 +316,7 @@ const Mimicry = (function privateMimicryClosure () { if (callback instanceof Function) { const module = this._modules.get(originalName); if (module) { - module.addCallback(callback); + module.addCallback(callback, window); } else { const asset = this._loadedAssets.get(originalName); if (asset) @@ -572,8 +572,12 @@ const Mimicry = (function privateMimicryClosure () { xhr.send(); } - function getCurrentHref() { - const script = document.currentScript || launcher; + 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 @@ -583,9 +587,9 @@ const Mimicry = (function privateMimicryClosure () { function loadedFileForModule (/*Module*/requester, /*String*/fileName) { const dep = this._modules.get(fileName); if (dep) - dep.addCallback(requester.incrementDependency.bind(requester, this)); + dep.addCallback(requester.incrementDependency.bind(requester, this, window), window); else - requester.incrementDependency(this); + requester.incrementDependency(this, window); } const launcher = document.currentScript; diff --git a/dependency.js b/dependency.js index c278fa8..56c6005 100644 --- a/dependency.js +++ b/dependency.js @@ -132,7 +132,7 @@ " doesn't start with baseUrl " + baseUrl + ", this is probably an error"); else - href = href.slice(baseUrl.length + 1); + href = href.slice(baseUrl.length); } let foundAmongLibs = false; diff --git a/module.js b/module.js index 2e7a9e3..d649141 100644 --- a/module.js +++ b/module.js @@ -41,11 +41,12 @@ * * @param {Mimicry} mimicry - Mimicry instance that was loading this module, * it is needed for calling "execute" if the dependencies are resolved + * @param {any} global - a global object for current platform * */ - incrementDependency (mimicry) { + incrementDependency (mimicry, global) { ++this._loaded if (this.allDependenciesResolved) - this.execute(mimicry); + this.execute(mimicry, global); } /** * If the modules is executed - calls the callback immediately @@ -57,10 +58,11 @@ * second: the value of this module * * @param {Function} callback - a callback to call + * @param {any} global - a global object for current platform * */ - addCallback (/*Function*/callback) { + addCallback (/*Function*/callback, global) { if (this._executed) - callback.call(null, window, this._value); + callback.call(null, global, this._value); else this._callbacks.push(callback); } @@ -70,8 +72,9 @@ * * @param {Mimicry} mimicry - an instance of Mimicry that was loading this module * It is needed to acquire values of dependencies + * @param {any} global - a global object for current platform * */ - execute (mimicry) { + execute (mimicry, global) { if (this.executed) throw new Error("An attempt to execute a module for the second time"); @@ -81,7 +84,7 @@ const name = this._dependencies[i].name; values.push(mimicry.getModule(name) || mimicry.getAsset(name)); } - this._value = this._body.call(null, window, values); + this._value = this._body.call(null, global, values); } else { this._value = null; } @@ -91,7 +94,7 @@ delete this._dependencies; for (let i = 0; i < callbacks.length; ++i) - callbacks[i].call(null, window, this._value); + callbacks[i].call(null, global, this._value); } } diff --git a/node.js b/node.js new file mode 100644 index 0000000..bf2a21d --- /dev/null +++ b/node.js @@ -0,0 +1,531 @@ +"use strict"; +const path = require('path'); + +const {BasicError, LoadError, FileError} = require("./errors") +const LoadingFile = require("./loadingFile"); +const Queue = require("./queue"); +const Dependency = require("./dependency"); +const Type = Dependency.Type; +const Module = require("./module"); +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 = new 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); + } + + /** + * 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._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 (!(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} 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 + */ + module (dependencies, callback) { + const href = getCurrentHref(); + 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; + + this.load(elName, loadedFileForModule.bind(this, module, elName), contentType); + } + + if (module.allDependenciesResolved) + module.execute(this, global); + } + + /** + * 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 true;} + + /** + * @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 true;} + + /** + * 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 callCallback(/*Function*/callback, /*String*/originalName) { + if (callback instanceof Function) { + const module = this._modules.get(originalName); + if (module) { + module.addCallback(callback, global); + } else { + const asset = this._loadedAssets.get(originalName); + if (asset) + callback(asset); + else + callback(global); + } + } +} + +/** + * @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: + try { + require("./" + path); + this._loadedFiles.add(path); + setTimeout(boundLoad, 0); + } catch (e) { + this._errors.set(path, e); + setTimeout(boundError, 0); + } + break; + case Type.css: + setTimeout(boundLoad, 0); //todo just for now, don't know what to do yet with css here + //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); + + 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); + checkNextElement.call(this); + notifyProgressSubscribers.call(this); +} + +function onError (/*String*/path) { + 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); + checkNextElement.call(this); + notifyProgressSubscribers.call(this); +} + +function onModuleError (/*String*/path) { + 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); + checkNextElement.call(this); + notifyProgressSubscribers.call(this); +} + +function cleanUp (/*String*/path) { + const loading = this._loadingFiles.get(path); + this._loadingFiles.delete(path); + this._errors.delete(path); + switch (loading.contentType) { + case Type.js: + //todo don't know yet + break; + case Type.css: + //todo don't know yet + break; + case Type.json: + case Type.binary: + case Type.text: + //todo dont know yet + break + } + loading.destructor(); +} + +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); + } +} + +function getCurrentHref() { + return path.relative(__dirname, _getCallerFile()); +} + +function _getCallerFile() { + let caller; + try { + const err = new Catcher(); + let current = err.stack.shift().getFileName(); + + while (err.stack.length) { + caller = err.stack.shift().getFileName(); + + if (current !== caller) + break; + } + } catch (e) {} + + return caller; +} + +class Catcher { + constructor(...args) { + const err = new Error(args); + Error.prepareStackTrace = Catcher.prepareStackTrace; + this._stack = err.stack; + Error.prepareStackTrace = defaultPrepareStackTrace; + } + get stack () {return this._stack;} + static prepareStackTrace (err, stack) {return stack;} +} +const defaultPrepareStackTrace = Error.prepareStackTrace; + +function loadedFileForModule (/*Module*/requester, /*String*/fileName) { + const dep = this._modules.get(fileName); + if (dep) + dep.addCallback(requester.incrementDependency.bind(requester, this, global), global); + else + requester.incrementDependency(this, global); +} + +const instances = []; + +module.exports = Mimicry; \ No newline at end of file diff --git a/test/index.html b/test/index.html index 469924e..155c54e 100644 --- a/test/index.html +++ b/test/index.html @@ -5,7 +5,7 @@ Title - + Initial page diff --git a/test/index.js b/test/index.js index 9eea9c7..32feff5 100644 --- a/test/index.js +++ b/test/index.js @@ -1,5 +1,7 @@ "use strict"; +const Mimicry = require("../node"); + test(); function test () { if (!Mimicry) { @@ -12,8 +14,11 @@ function test () { let mimicry; try { - mimicry = new Mimicry(); - window.mimicry = mimicry; + mimicry = new Mimicry({ + baseUrl: "test" + }); + global.mimicry = mimicry; + global.Mimicry = Mimicry; log("Mimicry was successfully instantiated"); } catch (e) { log("Error instantiating Mimicry"); @@ -37,8 +42,5 @@ function test () { } function log (message) { - const node = document.createTextNode(message); - const br = document.createElement("br"); - document.body.appendChild(node); - document.body.appendChild(br); + console.log(message); } \ No newline at end of file diff --git a/test/index_main.html b/test/index_main.html index 08afdef..630115a 100644 --- a/test/index_main.html +++ b/test/index_main.html @@ -4,7 +4,7 @@ Title - + Initial page diff --git a/test/main.js b/test/main.js new file mode 100644 index 0000000..9eea9c7 --- /dev/null +++ b/test/main.js @@ -0,0 +1,44 @@ +"use strict"; + +test(); +function test () { + if (!Mimicry) { + log("Mimicry class was not loaded"); + return; + } + + log("Mimicry class is loaded"); + log("Mimicry ready: " + Mimicry.ready); + + let mimicry; + try { + mimicry = new Mimicry(); + window.mimicry = mimicry; + log("Mimicry was successfully instantiated"); + } catch (e) { + log("Error instantiating Mimicry"); + return; + } + + try { + mimicry.module(["module"], function (global, [module]) { + log("Mimicry empty module successfully resolved"); + log("The background now is supposed to be bluish if you're testing in browser") + if (module === true) + log("Value returned from additional module is correct"); + else + log("Value returned from additional module is \"" + module + "\", which is incorrect"); + }); + log("Successfully launched Mimicry empty module"); + } catch (e) { + log("Error launching Mimicry empty module"); + //return; + } +} + +function log (message) { + const node = document.createTextNode(message); + const br = document.createElement("br"); + document.body.appendChild(node); + document.body.appendChild(br); +} \ No newline at end of file