'use strict'
/**
___ ____ __ ____ ___
/ __)( _ \ /__\ ( _ \ / __)
___\__ \ )___//(__)\ ) /( (__
(_______/(__) (__)(__)(_)\_) \___) SPA Rational Code
By Mike & Nike
This file is part of Sparc by Mike & Nike.
Sparc is free software:
- you can redistribute it and/or modify it under the terms of the GNU General Public License,
as published by the Free Software Foundation, either version 3 of the License,
or (at your option) any later version.
- Sparc is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
See the GNU General Public License for more details.
Get your copy of the GNU General Public License at .
* @category Core
* @hideconstructor
* @tutorial app-01-overview
*/
class Sparc {
/**
* Genaral app configuration, as soon as it is loaded
* @tutorial app-02-configuration
* */
config = {};
/** Classes definitions for MVC + Sparc libs follow the rule script-name = instantiable class name */
LoadedClasses = {};
/** Thirdparties and other libs don't necessarily have a class, or this naming conv. then store path only. */
LoadedScripts = [];
/** @type User */
User = null
/** @type Assets */
Assets = null
/** @type Router */
Router = null
/** @type Events */
events = null
/** @type Array */
latestErrors = [];
/**
*
* @param {*} ref
* @param {*} cls
* @param {*} replace
*/
registerClass(ref, cls, replace=false) {
if(replace || (!this.LoadedClasses.hasOwnProperty(ref))){
this.LoadedClasses[ref] = cls;
} else {
console.warn('Attempting to register already defined class ', ref);
}
}
/**
*
*/
onBootReady() {
console.log('Sparc boot loaded...');
// Instances shortcuts
this.User = new this.LoadedClasses.User(); // This is User base class (no login)
// Static classes shortcuts
this.Assets = Assets;
// General app config are loaded here
this.Assets.loadJson({ 'name': 'config.json',
'path': '/app/config/',
}).then(this.onConfigLoaded.bind(this));
// Instances shortcuts
this.events = new this.LoadedClasses.Events(); // This is User base class (no login)
this.addEvent = this.events.addEvent.bind(this.events);
}
/**
*
*/
onWindowReady() {
// MVC not yet loaded here, see remark below about loading base classes.
Loader.loadScripts({ 'basepath':'/core/libs/',
'scripts': ['Router', 'Logger', 'Assets','Events'],
'dependencies':{ 'Router': ['../baseClasses/User'] },
}).then(this.onBootReady.bind(this));
}
/**
*
* @param {*} config
*/
onConfigLoaded(config){
console.log('App config loaded...');
if(!config) {
console.error('Could not load SPARC config file !?');
return;
}
this.config = config;
if(('logger' in config) && ('enabled' in config.logger) && (config.logger.enabled)
&& ('levels' in config.logger) && ('postUrl' in config.logger)){
window.console = this.logger(window.console, config.logger);
}
this.Assets.defaults = config.assets;
if( ('userLib' in config) && ('className' in config.userLib) && (config.userLib.className!='')) {
Loader.loadScripts({ 'basepath':'/app/libs/',
'scripts': [config.userLib.className],
'dependencies':{ }, // Dependencies of the User class from config ?
}).then(()=>{
this.User = new this.LoadedClasses.User(); // Re-instantiate extnded class
// Upon creation, we are ourselves, but it might change later. (no OTS yet) Philosophical, isn't it :D
this.currentUser = this.User;
this.onUserClassReady();
});
} else {
this.onUserClassReady();
}
}
/**
*
*/
onUserClassReady(){
this.User.checkAuthenticated(()=>{
if(this.User.isAuthenticated) {
window.onbeforeunload = () => "Do you really want to leave EISMEA application ?";
// Loading base Classes, who can now use app.config.
// If later we do multi-app then one config per app, and baseclass instantiated with its own config.
Loader.loadScripts({
'basepath':'/core/baseClasses/',
'scripts': [
'Model',
'View',
'MasterController'
], // Look momy: sounds just like MVC ;-)
'dependencies': {
'MasterController': ['Controller']
}, // Dependencies of the base classes ?
}).then(this.loadLibraries.bind(this));
// Now load & start messageBus (must be authenticated, but can by done aside of loading the MVC core)
if((this.config.messageBus.enabled) && ("Worker" in window) && ('WebSocket' in window)) {
Loader.loadScripts({ 'basepath':'/core/libs/',
'scripts': ['MessageBus'],
}).then(() => {
this.MessageBus = new this.LoadedClasses.MessageBus(this.config.messageBus, this.User.getMessageBusUserInfo());
});
} else if(this.config.messageBus.enabled){
console.warn("Could not register MessageBus worker: Browser too old ?");
}
} else {
console.log('User is not Authenticated, redirect to Login page');
this.User.gotoLogin();
}
});
}
/**
*
*/
loadLibraries(){
// Loading custom libs & intermediary classes
let allPromises = [];
let lib, libclass;
if('libsBaseClasses' in this.config.router){
for(lib of this.config.router.libsBaseClasses) {
if(Array.isArray(lib.onlyIfClasses) && !lib.onlyIfClasses.every(cls => (cls in this.LoadedClasses))) continue
libclass = lib.classes[0]; // Let all assets be dependencies of the first script (if many)
Loader.AssetsDependencies[libclass] = lib.assets;
allPromises.push( Loader.loadScripts({
'basepath':'/app/libs'+lib.path,
'scripts': lib.classes,
'dependencies': lib.dependencies,
})
);
}
}
Promise.allSettled(allPromises).then(this.onMVCReady.bind(this));
}
/**
*
*/
onMVCReady() {
// Now that we are authenticated, we have the config and the MVC
// load the top routes and then route !
this.Assets.loadJson({ 'name': 'baseRoutes.json', 'path': '/app/config/' })
.then(
(topRoutes) =>{
if(!this.Router) {
//console.log('Top routes loaded...', app.config);
var routerOptions = {
'defaults' : this.config.router,
'routes': topRoutes,
'callback': () =>{ } // Called when *routed* (not when instanciated)
};
if('getRolesFrom' in this.config.router){ //Role can be an array of string or string representing a function
var isCallable = Function([],"return(typeof("+this.config.router.getRolesFrom+")=='function');")
if(isCallable()) routerOptions['roles'] = Function([],"return("+this.config.router.getRolesFrom+"());");
else routerOptions['roles'] = this.config.router.getRolesFrom;
} else routerOptions['roles']='';
this.Router = new Router(routerOptions);
}
this.Router.route();
}
);
}
}
// Let the browser cache boot loader components or not ? (true in prod or if you don't work on core)
const bootBrowserCache = false;
/* Here is Sparc bootstrap, depends only on Loader core class defined below */
if(typeof(app)=='undefined') var app = new Sparc();
window.onload = app.onWindowReady.bind(app);
/**
___ _____ __ ____ ____ ____
( ) ( _ ) /__\ ( _ \( ___)( _ \
) (_ )(_)( /(__)\ )(_) ))__) ) /
(____)(_____)(__)(__)(____/(____)(_)\_) for SPARC
By Mike & Nike
This static class contains the mechanics for loading javascripts.
It will load any needed dependancies for you (with the help of the dependancies file)
It can be called just boot-load you application and all its dependancies ,
and/or, alternatively, you can load widgets yourself anytime.
* @static
* @hideconstructor
* @category Core
* @todo found some direct reference to the app global var which is a bit sloppy
*/
class Loader {
static Dependencies = {};
static LoadedDepFiles = [];
static AssetsDependencies = {};
/**
* @private
*/
static resolveDeps(imps, deps){
const leveler = (levels, root, l, trail=[]) => {
if(deps.hasOwnProperty(root)){
for(var dep of deps[root]){
if(!levels.hasOwnProperty(dep)) levels[dep] = -1;
if(levels[dep] {
fetch(basePath+deps, { headers: { "Content-Type": "application/json; charset=utf-8" }})
.then(res => res.json())
.then(response => {
Loader.Dependencies = { ...Loader.Dependencies, ...response}; // Merge with known deps
Loader.LoadedDepFiles.push(deps);
resolve();
})
.catch(err => { console.error('Error loading dependencies'); Loader.Dependencies = {}; reject(); });
});
} else {
Loader.DependenciesLoader = new Promise((resolve, reject) => { resolve(); }); //Already loaded, nothing to do here
}
} else if(typeof(deps)=='object') { // deps is the dep-tree object
Loader.DependenciesLoader = new Promise((resolve, reject) => {
Loader.Dependencies = { ...Loader.Dependencies, ...deps }; // Merge with known deps
resolve(); // already ready ! ;-)
});
} else { console.error('Bad dependencies !?'); return(false); }
// Prepare a synchronous chain of asynchronous loads
Loader.DependenciesLoader.then(()=>{
var byLevelImps = Loader.resolveDeps(scriptNames, Loader.Dependencies);
var total = Object.values(byLevelImps).reduce((tot,v)=> (tot+v.length), 0);
document.dispatchEvent(new CustomEvent("LoaderProgressAddTodo", {'detail': { 'importID':importIdPrfx, 'addValue': total }}));
for(var key in Object.getOwnPropertyNames(byLevelImps).sort().reverse()){
CurrentImportChain[importIdPrfx+'_'+key] = {'imps': byLevelImps[key], 'path':basePath };
}
var firstImportId = importIdPrfx+'_'+key;
document.addEventListener('LoaderAllReady', function(evt) { // Next async imports in the sync chain
var nextImpNb = evt.detail.substr(0,evt.detail.lastIndexOf('_')+1)+(parseInt(evt.detail.substr(evt.detail.lastIndexOf('_')+1))-1);
if(evt.detail != importIdPrfx+'_0'){
if(CurrentImportChain.hasOwnProperty(nextImpNb)) {
Loader._import(CurrentImportChain[nextImpNb]['imps'], nextImpNb,CurrentImportChain[nextImpNb]['path']);
}
}
});
// First load all assets
Loader.LoadAssetDependencies(byLevelImps).then( () => {
// Start the synchronous chain
Loader._import(CurrentImportChain[firstImportId]['imps'], firstImportId,CurrentImportChain[firstImportId]['path']);
});
});
}
/**
*
* @param {*} byLevelImps
* @returns {Array}
*/
static LoadAssetDependencies(byLevelImps){
let allImports = [];
for(var level in byLevelImps) allImports=[...allImports, ...byLevelImps[level]];
allImports = Array.from(new Set(allImports)); //remove dups
allImports = allImports.map(this.path2ClassName);
let allPromises = [];
for(var classname of allImports){
for(var assetType in Loader.AssetsDependencies[classname]){
if(!Array.isArray(Loader.AssetsDependencies[classname][assetType])) {
console.warn(`Bad config: Asset dependencies for ${classname} - ${assetType} is not an array !?`)
continue;
}
for(var assetObj of Loader.AssetsDependencies[classname][assetType]){
if((typeof(assetObj) != 'object') || (!('name' in assetObj))) {
console.warn(`Bad config: One asset dependency for ${classname} - ${assetType} has no name !?`)
continue;
}
switch(assetType){
case 'fonts': allPromises.push(app.Assets.loadFont(assetObj));
break;
case 'styles': allPromises.push(app.Assets.loadCss(assetObj));
break;
case 'html': allPromises.push(app.Assets.loadHtml(assetObj));
break;
case 'views': assetObj.path = '/app/views/';
allPromises.push(app.Assets.loadHtml(assetObj));
break;
case 'images': allPromises.push(app.Assets.getImage(assetObj));
break;
case 'json': allPromises.push(app.Assets.loadJson(assetObj));
break;
case 'sfx': allPromises.push(app.Assets.loadSound(assetObj));
break;
}
}
}
}
return(Promise.allSettled(allPromises));
}
/**
*
* @param {*} src
* @returns {Promise}
*/
static loadScript(src) {
return new Promise((resolve, reject) => {
const script = document.createElement('script')
script.type = 'text/javascript'
script.onload = resolve
script.onerror = reject
script.src = src
document.head.append(script)
});
}
/**
* @private
*/
static path2ClassName(scriptName){
scriptName = scriptName.substr(scriptName.lastIndexOf('/')+1);
if(scriptName.indexOf('.')==-1) scriptName+='.'; // also pure scriptname or path without extension compatible !
return(scriptName.substr(0,scriptName.lastIndexOf('.')));
}
/**
* @private
*/
static url2Path(url){
var x = new URL(url);
return(x.pathname);
}
/**
* @private
*/
static _import(names, importID, basePath){ // Here, all imports are made asynchronously, in parallel.
var allLoaded = true;
var nbLeft = names.length;
for(var name of names){
var className = this.path2ClassName(name);
if(name.substring(0,4)!='http') var scriptFp = (basePath + '/' + name).replace(/\/+/g,'/')+'.js';
else var scriptFp = name;
if( (!(className in app.LoadedClasses)) && (app.LoadedScripts.indexOf(scriptFp)<0) ) {
allLoaded = false;
if( !bootBrowserCache ) scriptFp += '?'+importID;
Loader.loadScript(scriptFp)
.then((result) => {
var scriptName= this.url2Path(result.currentTarget.src);
// MCV classes and internal ones put themselves in app.loadedClasses,
// Non-Sparc scripts don't so save their path.
if( ('router' in app.config) &&
!(scriptName.startsWith(app.config.router.controllersPath)) &&
!(scriptName.startsWith(app.config.router.modelsPath)) &&
!(scriptName.startsWith(app.config.router.viewsPath)) &&
!(scriptName.startsWith('/core/'))
) app.LoadedScripts.push(scriptName);
var className = this.path2ClassName(result.currentTarget.src);
document.dispatchEvent(new CustomEvent('Loader'+className+'Ready', { 'detail': importID}));
document.dispatchEvent(new CustomEvent("LoaderProgressIncrement", {'detail': { 'importID': importID, 'success': true }}));
nbLeft--;
if(nbLeft<=0){
document.dispatchEvent(new CustomEvent("LoaderAllReady", { 'detail': importID }));
}
})
.catch((error) => {
if((typeof(error)!='undefined') && (typeof(error.currentTarget)!='undefined')){
var scriptName= error.currentTarget.src;
var className = this.path2ClassName(error.currentTarget.src);
console.error('Could not import Class '+className);
document.dispatchEvent(new CustomEvent("LoaderError", { 'detail': {'importID': importID, 'className': className }}));
} else console.error('Script loading error '+error);
document.dispatchEvent(new CustomEvent("LoaderProgressIncrement", {'detail': { 'importID': importID, 'success': false }}));
nbLeft--;
});
} else nbLeft--;
}
if(allLoaded){
for(var name of names) document.dispatchEvent(new CustomEvent('Class'+name+'Ready', { 'detail':importID }));
document.dispatchEvent(new CustomEvent("LoaderAllReady", { 'detail':importID }));
}
}
/**
* Load a list of scripts.
* @static
* @param {string} basepath - base path (something like /sccripts/)
* @param {Array} scripts - array of scripts to load
* @param {(string|object)} dependencies - Last minute Dependancies :tree object ( like {'scriptName': ['deps1', 'dep2,...]} ) or path to its json file.
* @param {function} callback - Called when everything is loaded
* @returns {Promise} A promise resolved whn-en all is loaded
*/
static loadScripts(args){
// Start by filtering out scripts we already have
if(!args.refresh) {
args.scripts = args.scripts.filter((sname)=>{
var className = this.path2ClassName(sname);
return(!(className in app.LoadedClasses) && (!(sname in app.LoadedScripts)));
});
if(args.scripts.length==0) {
if( (args.hasOwnProperty('callback')) && (typeof(args.callback)=='function') ){
args.callback({});
}
return(new Promise((resolve) => { resolve(); }));
}
}
var myImportID = crypto.randomUUID();
if(!args.hasOwnProperty('dependencies')) args.dependencies = [];
if(!args.hasOwnProperty('basepath')) args.basepath = '';
return(new Promise((resolve, fail) => {
document.addEventListener('LoaderAllReady', (evt) => { if(evt.detail==myImportID+'_0') resolve(); } );
document.addEventListener('LoaderError', (evt) => { if(evt.detail==myImportID+'_0') fail(); } );
Loader.Import(args.scripts, args.dependencies, myImportID, args.basepath);
})
);
}
/**
* Load a list of views.
* @static
* @param {array} Views - array of Views to load
* @returns {string} The unique ID of the import
*/
static async loadViews(views){
// Start with views templates
let allPromises = []
let templates = []
let scripts = []
let dependencies = {}
for(var dep of views){
if(typeof(dep) == 'string') {// just add view as dependency of controller, with correct path
templates.push(dep+'.html')
scripts.push(dep)
} else if(typeof(dep) == 'object'){ // This view depends on other views
scripts.push(dep.view);
dependencies[dep.view]=dep.dependencies//.map(x=>'/app/views/'+x)
templates.push(dep.view+'.html')
}
}
for(let view of templates){
let assetObj = { path: '/app/views/', name: view }
allPromises.push(app.Assets.loadHtml(assetObj));
}
await Promise.allSettled(allPromises);
// Finish with views scripts
return(this.loadScripts({
basepath:'/app/views/',
scripts: scripts,
dependencies: dependencies,
}));
}
}
/**
* POLYFILLS
*/
if(typeof(crypto.randomUUID)!='function'){
crypto.randomUUID = () => {
var buf = new Uint8Array(14);
crypto.getRandomValues(buf);
var uuid = Array.from(buf, byte => ('0' + (byte & 0xFF).toString(16)).slice(-2)).join('');
return(
uuid.substring(0,8) + '-' +
uuid.substring(10,4) + '-' +
uuid.substring(14,4) + '-' +
uuid.substring(18,4) + '-' +
uuid.substring(22)
);
}
}
if(typeof(typeof(Promise.allSettled))!='function'){
Promise.allSettled = ((promises) => Promise.all(
promises.map(p => p
.then(value => ({
status: "fulfilled",
value
}))
.catch(reason => ({
status: "rejected",
reason
}))
)
));
}