unclean SPARC

This commit is contained in:
STEINNI
2025-08-27 07:03:09 +00:00
commit f308460931
430 changed files with 54426 additions and 0 deletions
+566
View File
@@ -0,0 +1,566 @@
'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 <https://www.gnu.org/licenses/>.
* @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<Object> */
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. <br>
It will load any needed dependancies for you (with the help of the dependancies file) <br>
It can be called just boot-load you application and all its dependancies , <br>
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]<l) levels[dep]=l;
if(trail.indexOf(dep)<0){ // detect circular dependency to avoid infinite recursion
let curtrail = [...trail];
trail.push(dep);
leveler(levels, dep , l+1, trail);
trail = curtrail;
} else console.error('Circular depencency with '+dep, trail);
}
}
}
var levels={};
// Build the dep tree of things we need, noting the depth.
for(var imp of imps) { leveler(levels, imp, 1); } // Level starts at 1, zero reserved for roots
// Add requested imports as last, unless already with higher priority (tree shaking)
for(var imp of imps) { if(!levels.hasOwnProperty(imp)) levels[imp] = 0; }
// Group by levels, as their loading can be paralellized (whereas on level must wait on the prev. one)
var name, level; var byLevel = {};
for([name, level] of Object.entries(levels)) {
if(!byLevel.hasOwnProperty(level)) { byLevel[level]=[]; }
byLevel[level].push(name);
}
return(byLevel);
}
/**
* @private
*/
static Import(scriptNames, deps, importIdPrfx, basePath = './'){ // Will start with real import (_import) only when / if dependencies are loaded.
var CurrentImportChain = {};
if(typeof(deps)=='string') { // deps is a path to a json file containing the dep tree
if( (deps!='') && (Loader.LoadedDepFiles.indexOf(deps)<0) ){
Loader.DependenciesLoader = new Promise((resolve, reject) => {
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<Promise>}
*/
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<string>} 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
}))
)
));
}
+173
View File
@@ -0,0 +1,173 @@
/**
* @category Core
* @subcategory Application
*/
class Controller {
static _template = null;
static _contents = [];
static _currentContent = null;
/**
*
*/
constructor() { }
/**
*
*/
destructor() {}
/**
* @async
* @param {*} name
* @param {*} args
* @param {*} data
* @returns {View}
*/
loadView(name, args, data) {
args = args || {};
args.name = name+'.html';
args.className = name.split('/').pop();
args.onContentLoaded = args.onContentLoaded || this.appendHTML;
args.path = app.config.router.viewsPath;
let request = app.Assets.loadHtml(args);
return request.then(args.onContentLoaded.bind(this, args, data));
}
/**
*
* @param {*} args
* @param {*} data
* @param {*} html
* @returns {View}
*/
appendHTML(args,data, html) {
if(!args.target) {
args.target = document.body;
} else {
if(typeof args.target == 'string') {
let selector = null;
let content = document.body;
let crumbs = args.target.split('@')
if(crumbs.length > 1) {
let targetId = crumbs.pop()
content = Controller._contents.find(o => o.view._sparcId == targetId).view.el;
}
selector = crumbs.join('@');
args.target = content.querySelector(selector);
}
}
let container = document.createElement('div');
container.innerHTML = Controller.processTemplate(args.name, html, data);
let parent = args.target;
parent.innerHTML = '';
while(container.children.length > 0) {
parent.appendChild(container.children[0]);
}
let view = new app.LoadedClasses[args.className]();
view._className = args.name.replace('.html', '');
Controller._currentContent = view;
Controller._contents.push({view: view, dom: parent, type: 'nested'});
view.el = parent;
view.el.setAttribute('sparc-id', view._sparcId);
view._controller = this;
//view.DOMContentLoaded(data);
return view;
}
/**
*
* @param {*} content
*/
unloadView(content) {
app.events.clear(content.view._sparcId);
Controller._contents = Controller._contents.filter(o => o.view._sparcId != content.view._sparcId);
content.view.DOMContentRemoved();
content.view.el.remove();
}
/**
*
* @param {*} name
* @param {*} args
*/
loadModel(name, args) { }
/**
*
* @param {*} id
* @returns {View}
*/
static getContentById(id) { return Controller._contents.find(o => o.view._sparcId == id); }
/**
*
* @param {*} name
* @param {*} html
* @param {*} templateData
* @returns {string}
*/
static processTemplate(name, html, templateData) {
// Define a function inside of which templateData keys will become local variables
// AND where templateData values will become thos variable values ;-)
// Drawback is that complex epression
function evalaluateTemplate(tpl, params) {
var interpretor = Function(...Object.keys(params), 'return('+tpl+');' );
return interpretor(...Object.values(params));
}
// Define a function inside of which a string of a variable name becomes that variable
// and where we check tht the corresponding (evaluable) expression in templateData is defined
function checkExpression(varname, params) {
var code = ' try{var t=typeof('+varname+');} catch(e){ t="badexpr"; } return(t)';
var interpretor = Function(...Object.keys(params), code );
return interpretor(...Object.values(params));
}
// Preflight check to avoid template crashes on undefined expressions
const rgxp = /\$\{(.*?)\}/gm;
let m = null;
templateData = templateData || {};
while ((m = rgxp.exec(html)) !== null) {
let safeType = checkExpression(m[1],templateData);
if(safeType == 'undefined') {
// variable undefined in templateData: add an empty one
console.warn(`In template ${name}, ${m[1]} is not defined !`);
templateData[m[1]]='';
} else if(safeType == 'badexpr') {
// unevaluable expression : remove it from template
console.warn(`In template ${name}, Bad expression: ${m[1]}`);
var escsearch = m[1].replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
var replrexp = new RegExp('\\$\\{'+escsearch+'\\}','gm')
html = html.replace(replrexp,'');
}
}
// Now that all remaining expressions are OK, evaluate the template via backtits !
return(evalaluateTemplate('`' + html + '`', templateData));
}
}
app.registerClass("Controller", Controller);
+83
View File
@@ -0,0 +1,83 @@
/**
* @category Core
* @subcategory Application
* @extends Controller
*/
class MasterController extends Controller {
content = null;
/**
*
*/
constructor() {
super();
if(app.MessageBus) this.onBusEnabled()
}
/**
* Called on init, if bus enabled (probably not yet connected)
*/
onBusEnabled() { app.MessageBus.whenConnected(this.onBusConnected.bind(this)) }
/** Called on bus connection
* Might be called again upon drop+reconnect
* Good place to put general subscriptions, to ensure
* you'll resubscribe after drop+reconnect
*/
onBusConnected(){ }
/**
*
* @param {*} name
* @returns {View}
*/
useTemplate(name) {
let className = name.split('/').pop(0);
let args = {};
if(name) {
if(!Controller._template || (Controller._template.name != className)) {
args.onContentLoaded = this.onTemplateLoaded;
return(this.loadView(name, args));
}
}
return(new Promise((resolve) => { resolve(null); }));
}
/**
*
* @param {*} args
* @param {*} data
* @param {*} html
*/
onTemplateLoaded(args,data, html) {
let container = document.createElement('div');
container.innerHTML = html;
this.el = document.body;
this.el.innerHTML = '';
while(container.children.length > 0) {
this.el.appendChild(container.children[0]);
}
let view = new app.LoadedClasses[args.className]();
Controller._template = {name: args.className, view: view, dom: this.el};
view.el = this.el;
view.DOMContentLoaded();
this.content = Controller._template.view;
this.ControllerReady();
}
/**
*
*/
ControllerReady() {}
}
app.registerClass('MasterController', MasterController);
+128
View File
@@ -0,0 +1,128 @@
/**
* Base model class
*
* @author Nicolas Stein
* @author Michael Fallise
* @version 1.2
* @category Core
* @subcategory Application
*/
class Model {
/**
*
* @param {object} response
* @returns {object}
*/
_processResponse(response, resolve, reject){
if(response.ok){ // All ok
if( (response.headers.get('Content-Type')) && (response.headers.get('Content-Type').toLocaleLowerCase().indexOf('application/json')>-1) ) {
resolve(response.json());
} else {
resolve(
response.text().then(rawResponse => {
if(rawResponse != '') {
console.warn('Server did not declare response as Json...trying to parse it anyway', rawResponse)
return(JSON.parse(rawResponse));
} else if(response.status==204) return(true); // Normal to have no content, so make sure other layers see it as success
else return('');
})
)
}
} else { // server error
// server error with json error details
if( (response.headers.get('Content-Type')) && (response.headers.get('Content-Type').toLocaleLowerCase().indexOf('application/json')>-1) ){
response.json().then(
cleanJson => { // json error details is clean
let error = {
'code': response.status,
'displayMessage': (cleanJson.error.displayMessage || ' !!No debugMessage from server!!'),
'debugMessage': response.statusText+'\n'+(cleanJson.error.debugMessage || ' !!No debugMessage from server!!'),
}
this.onRequestError(error);
reject(error);
},
text => {
let error = { // json error details is garbage
'code': response.status,
'displayMessage': 'Could not request to server!?',
'debugMessage': 'Additionally, bad Json returned :', text,
}
this.onRequestError(error);
reject(error);
}
)
} else { // server error with text return
response.text().then(
text => {
let error = {
'code': response.status,
'displayMessage': 'Could not request to server!?',
'debugMessage': `Server responded: ${text}`,
}
this.onRequestError(error);
reject(error);
},
)
}
}
}
/**
* Generic API service call
* @param {string} uri
* @param {string} method
* @param {object} payload
* @returns {object}
*/
request(uri, method, payload) {
// Fetch chooses its own resolve/reject (see https://developer.mozilla.org/en-US/docs/Web/API/fetch)
// Errors like 404 are not rejected. and we can't change the -already done- resolve/reject choice.
// therefore we return our own promise.
if((!method) || (!uri)) {
return(
new Promise((resolve, reject) => reject(
{ sucess :false,
payload: null,
error: { displayError: 'Server Error (no endpoint)',
debugError: 'Missing endpoint' },
})
))
}
return (
new Promise((resolve, reject) => {
let body = null;
if(method.toLowerCase()!='get') body = payload ? JSON.stringify(payload): null;
else if(payload) uri += '?'+Object.entries(payload).map(kv => kv.map(encodeURIComponent).join("=")).join("&");
fetch(uri, {
method: method,
headers: { 'Accept': 'application/json', 'Content-Type':'application/json'},
body: body,
credentials: 'include', //By nike: mandatory for MT to get session !!!
})
.then( response => {
this._processResponse(response, resolve, reject);
})
.catch(debugMessage => { // Other errors like metwork, protocol, CORs errors, or 200 but bad JSON
let error = {
'displayMessage': 'Could not request to server!?',
'debugMessage': debugMessage,
'code': '4XX' // non http but >299
}
this.onRequestError(error);
reject(error);
})
})
);
}
/**
* Request error handler
* @param {object} error
*/
onRequestError(error) { console.error('Ajax request error:', error); }
}
app.registerClass('Model', Model)
+53
View File
@@ -0,0 +1,53 @@
'use strict'
if(typeof(app)=='undefined') var app = {};
/**
__ __
( )( ) ___ ____ ____
)( )( / __)( ___)( _ \
)(__)( \__ \ )__) ) /
(______)(___/(____)(_)\_)
By Nike
This file is part of Widgets by Nike from Nicsys (info@nicsys.eu).
Widgets 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.
Widgets 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 <https://www.gnu.org/licenses/>.
* @category Core
* @subcategory Application
*/
class User {
authenticationDone = false;
isAuthenticated = false;
identity = {};
roles = [];
constructor(){ }
/**
*
* @param {*} callBack
*/
checkAuthenticated(callBack){
this.authenticationDone = true
this.isAuthenticated = true
this.userInfo = {
'identity':{},
'roles': [],
};
callBack();
}
/**
* do nothing
*/
gotoLogin() { }
/**
* do nothing
*/
getMessageBusUserInfo() { }
}
app.LoadedClasses.User = User
+112
View File
@@ -0,0 +1,112 @@
/**
* @category Core
* @subcategory Application
*/
class View {
_sparcId = null;
_className = null;
_controller = null;
title = "";
description = "";
/**
*
* @param {*} parms
*/
constructor(parms) {
parms = parms || {};
this._sparcId = crypto.randomUUID();
this.title = parms.title || '';
this.description = parms.description || '';
}
/**
* base method called when content is appended to the document.
*/
DOMContentLoaded() {}
/**
* base method called when content is removed from the document.
*/
DOMContentFocused() {}
/**
* base method called when content is removed from the document.
*/
DOMContentBlured() {}
/**
* base method called when content is removed from the document.
*/
DOMContentRemoved() {}
/**
* acts as a shortcut for vanilla querySelector Element method.
* @param {*} selector
* @returns {Element|null}
*/
find(selector) { return this.el ? this.el.querySelector(selector): null; }
/**
* acts as a shortcut for vanilla querySelectorAll Element method.
* @param {*} selector
* @returns {Array<Element>}
*/
findAll(selector) { return this.el ? this.el.querySelectorAll(selector): null; }
/**
* wrapper for vanilla addEventListener Element method.
* @param {*} type
* @param {*} callback
*/
addEvent(type, callback) { app.addEvent(type, callback, this._sparcId); }
/**
* AddEvent to all found selector.
* @param {*} selector
* @param {*} type
* @param {*} callback
*/
addEventTo(selector, type, callback) {
for(let el of this.findAll(selector)) {
// Cannot use the wrapper because not attached to el, but to window
// app.addEvent(type, callback, this._sparcId);
//TODO: see with Mike if addEvent could include target element .
el.addEventListener(type, callback);
}
}
/**
* Add an action to all found selector.
* @param {*} selector
* @param {*} type
* @todo what is this used for ? deprecate ?
*/
addActions(selector, type) {
this.addEventTo(selector+'[action]', type, e => {
let ax = this['action_'+e.target.getAttribute('action')];
if(typeof(ax)== 'function') ax.bind(this)(e);
else console.warn('Bad action: '+e.target.getAttribute('action'));
});
}
/**
* aggregates the content inner key to the targeted selector, avoiding targeting other content.
* @param {string} selector
* @returns {string}
*/
scope(selector) { return `${selector}@${this._sparcId}`; }
/**
* Wrapper for Controller.loadView
* @param {*} name
* @param {*} args
* @async
* @returns {View}
*/
loadView(name, args) { return this._controller.loadView(name, args); }
}
app.registerClass('View', View)
+291
View File
@@ -0,0 +1,291 @@
/**
__ ___ ___ ____ ____ ___
/__\ / __)/ __)( ___)(_ _)/ __)
/(__)\ \__ \\__ \ )__) )( \__ \
(__)(__)(___/(___/(____) (__) (___/ for SPARC
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 <https://www.gnu.org/licenses/>.
* @category Core
* @subcategory Libraries
* @hideconstructor
*/
class Assets {
static defaults = {};
/**
* @type {object}
* @property {Array<object>} images
* @property {Array<object>} css
* @property {Array<object>} html
* @property {Array<object>} json
* @property {Array<object>} fonts
* @property {Array<object>} sfx
*/
static Store = { 'images':{}, 'css':{}, 'html':{}, 'json':{}, 'fonts':{}, 'sfx':{} };
/**
* @async
* @param {*} args
* @param {string} args.path
* @param {string} args.name
* @param {string} [args.id]
* @param {boolean} [args.refresh]
* @returns {Promise}
*/
static getImage(args){
if( (!args.hasOwnProperty('path')) || (args.path=='') ) var fpath = this.defaults.basePath+'images/'+args.name;
else var fpath = (args.path+'/'+args.name).replace(/\/+/g,'/').replace(/http(s)?:\//g, 'http$1://');
let aid = args.id || fpath;
if(this.Store.images.hasOwnProperty(aid) && (!args.refresh)) {
return(new Promise((resolve) => {
resolve(this.Store.images[aid]);
})
);
}
let node = document.createElement('img');
return(new Promise( (resolve, fail) => {
node.onload = (e) => {
this.Store.images[aid] = node;
resolve(node);
}
node.onerror= (e) => {
console.warn('Could not load IMAGE asset ',aid, fpath);
}
node.src = fpath+'?'+crypto.randomUUID();
// for img nodes, setting the src is sufficient to start loading, then trigger the onload
})
);
}
/**
*
* @async
* @param {*} args
* @param {string} args.path
* @param {string} args.name
* @param {string} [args.id]
* @param {boolean} [args.refresh]
* @returns {Promise}
*/
static loadJson(args){
if( (!args.hasOwnProperty('path')) || (args.path=='') ) var fpath = this.defaults.basePath+'json/'+args.name;
else var fpath = (args.path+args.name).replace(/\/+/g,'/').replace(/http(s)?:\//g, 'http$1://');
let aid = args.id || fpath;
if( (this.Store.json.hasOwnProperty(aid)) && (!args.refresh) ){
return(new Promise((resolve) => {
resolve(this.Store.json[aid]);
})
);
}
return( fetch(fpath+'?'+crypto.randomUUID())
.then(response => response.json())
.then(data => {
this.Store.json[aid] = data;
return(data);
})
.catch(error => {
console.warn('Could not load json asset:', aid, fpath, error);
})
);
}
/**
*
* @async
* @param {*} args
* @param {string} args.path
* @param {string} args.name
* @param {string} [args.id]
* @param {boolean} [args.refresh]
* @returns {Promise}
*/
static loadCss(args){
if( (!args.hasOwnProperty('path')) || (args.path=='') ) var fpath = this.defaults.basePath+'styles/'+args.name;
else var fpath = (args.path+args.name).replace(/\/+/g,'/').replace(/http(s)?:\//g, 'http$1://');
let aid = args.id || fpath;
let _notInIndex = (aid) => {
if( (!app.config.squeeze) || (!app.config.squeeze.packages) ) return(true)
for(let dragee of app.config.squeeze.packages) {
let ext = dragee.target.toLowerCase().substring(dragee.target.lastIndexOf('.'))
if((ext!='.css') || (!dragee.setIndexPage)) continue
for(let file of dragee.sources) {
if(file == aid) return(false)
}
}
return(true)
}
if( this.Store.css.hasOwnProperty(aid) && (!args.refresh) ) {
return(new Promise((resolve) => {
resolve(this.Store.css[aid]);
})
);
}
let node;
if( (this.Store.css.hasOwnProperty(aid)) && (args.refresh) ) node = this.Store.css[aid];
else {
node = document.createElement('link');
node.setAttribute('rel','stylesheet');
node.setAttribute('type','text/css');
}
return( new Promise( (resolve, fail) => {
node.onload = (e) => {
this.Store.css[aid] = node;
resolve(node);
}
node.onerror= (e) => {
console.warn('Could not load CSS asset ', aid, fpath);
fail(node);
}
node.setAttribute('href',fpath+'?'+crypto.randomUUID());
// for link nodes, setting the href is NOT sufficient to start loading
// , then trigger the onload. Must add it to the dom !
document.head.appendChild(node)
})
);
}
/**
* Loads an audio file.
* @async
* @param {*} args
* @param {string} args.path
* @param {string} args.name
* @param {string} [args.id]
* @param {boolean} [args.refresh]
* @returns {Promise}
*/
static loadSound(args){
if( (!args.hasOwnProperty('path')) || (args.path=='') ) var fpath = this.defaults.basePath+'sfx/'+args.name;
else var fpath = (args.path+args.name).replace(/\/+/g,'/').replace(/http(s)?:\//g, 'http$1://');
let aid = args.id || fpath;
if( (this.Store.sfx.hasOwnProperty(aid)) && (!args.refresh) ) {
return(new Promise((resolve) => {
resolve(this.Store.sfx[aid]);
})
);
}
return(new Promise((resolve, fail) => {
this.Store.sfx[aid] = new Audio();
this.Store.sfx[aid].setAttribute('aid', aid);
this.Store.sfx[aid].addEventListener('canplaythrough', (e) => resolve(e.target), { 'once':true });
this.Store.sfx[aid].addEventListener('error', (e) => fail(e.target), { 'once':true });
this.Store.sfx[aid].src = fpath+'?'+crypto.randomUUID();
return(this.Store.sfx[aid]);
})
);
}
/**
* Plays a loaded audio, loads it first if needed.
* @async
* @param {*} args
* @param {string} args.path
* @param {string} args.name
* @param {string} [args.id]
* @param {boolean} [args.refresh]
* @returns {Promise}
*/
static playSound(args){
// Nike: Promise when fulfilled when sfx loaded.
// If we want when sfx finished to play then
// stick a 'ended' event listener as resolver in a new promise
return(
this.loadSound(args).then( (snd) => { snd.play(); })
);
}
/**
*
* @async
* @param {*} args
* @param {string} args.path
* @param {string} args.name
* @param {string} [args.id]
* @param {boolean} [args.refresh]
* @returns {Promise}
* @todo check issue with undeclared aid
*/
static loadFont(args){
// something fishy here => aid not declared
let acronym = aid || args.name.substr(0,args.name.lastIndexOf('.'));
if( (!args.hasOwnProperty('path')) || (args.path=='') ) var fpath = this.defaults.basePath+'fonts/'+args.name;
else var fpath = (args.path+args.name).replace(/\/+/g,'/').replace(/http(s)?:\//g, 'http$1://');
let aid = args.id || fpath;
if( (this.Store.fonts.hasOwnProperty(aid)) && (!args.refresh) ) {
return(new Promise((resolve) => {
resolve([loaded_face, acronym]);
})
);
}
let ff = new FontFace(acronym,'url('+fpath+'?'+crypto.randomUUID()+')');
return(ff.load()
.then(loaded_face => {
document.fonts.add(loaded_face);
this.Store.fonts[aid] = loaded_face;
return([loaded_face, acronym]);
})
.catch(error => {
console.warn('Could not load FONT asset:' + fpath);
[null, acronym]
})
);
}
/**
* Loads an HTML file
* @async
* @param {*} args
* @param {string} args.path
* @param {string} args.name
* @param {string} [args.id]
* @param {boolean} [args.refresh]
* @returns {string}
*/
static loadHtml(args) {
if( (!args.hasOwnProperty('path')) || (args.path=='') ) var fpath = this.defaults.basePath+'html/'+args.name;
else var fpath = (args.path+args.name).replace(/\/+/g,'/').replace(/http(s)?:\//g, 'http$1://');
let aid = args.id || fpath;
if( (this.Store.html.hasOwnProperty(aid)) && (!args.refresh) ) {
let html = this.Store.html[aid];
return(new Promise((resolve) => {
resolve(html);
})
);
}
return(fetch(fpath + '?' + crypto.randomUUID())
.then(response => response.text())
.then(html => {
this.Store.html[aid] = html;
return html;
})
.catch(error => {
console.warn('Could not load HTML asset:', aid, fpath);
return(null);
})
);
}
}
app.LoadedClasses.Assets = Assets;
+98
View File
@@ -0,0 +1,98 @@
/**
*
* @category Core
* @subcategory Libraries
*/
class Events {
// collection of registered app events
static _registered = [];
constructor() {
this.channel = document.body;
}
/**
* Candidate for deprecation: registering callbacks sounds like shit load of issues
*
* @param {string} type
* @param {*} callback
* @param {string} scope
*/
addEvent(type, callback, scope) {
let available = Events._registered.find(o => o.type == type && o.scope == scope);
if(!available) {
switch(type) {
case 'resize':
window.addEventListener(type, callback);
break;
default:
this.channel.addEventListener(type, callback);
}
Events._registered.push({scope: scope, type: type, callback: callback})
}
}
removeEvent(type, callback, scope) {
let index = Events._registered.findIndex(o => o.type == type && o.scope == scope);
if(index != -1) {
switch(type) {
case 'resize':
window.removeEventListener(type, callback);
break;
default:
this.channel.removeEventListener(type, callback);
}
Events._registered.splice(index,1);
}
}
/**
*
* @param {string} type
* @param {*} data
*/
trigger(type, data) {
let event = new CustomEvent(type, {detail: data});
switch(type) {
case 'resize':
window.dispatchEvent(event);
break;
default:
this.channel.dispatchEvent(event);
}
}
/**
*
* @param {string} scope
*/
clear(scope) {
let i = 0;
while(i < Events._registered.length) {
let event = Events._registered[i];
if(event.scope == scope) {
switch(event.type) {
case 'resize':
window.removeEventListener(event.type, event.callback);
break;
default:
document.body.removeEventListener(event.type, event.callback);
}
Events._registered.splice(i, 1);
} else {
i++;
}
}
}
}
app.registerClass('Events', Events);
+145
View File
@@ -0,0 +1,145 @@
/**
__
( ) _____ ___ ___ ____ ____
)( ( _ )/ __) / __)( ___)( _ \
)(__ )(_)(( (_-.( (_-. )__) ) /
(____)(_____)\___/ \___/(____)(_)\_)
By Mike & Nike
This file is part of Sparc by Mike & Nike.
Widgets 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.
Widgets 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 <https://www.gnu.org/licenses/>.
* @category Core
* @subcategory Libraries
* @class
* @todo extract from app (this is just a hook for window.console)
* @param {object} nativeConsole a reference to the native browser console (Typically window.console )
* @param {object} config
*/
app.logger = function(oldCons, config){
const appendErr = (data) => {
data['errId'] = crypto.randomUUID()
app.latestErrors.push(data)
let maxentries = config.latestErrsMax || 10;
while(app.latestErrors.length > maxentries) app.latestErrors.shift()
}
const getCircularReplacer = () => {
const seen = new WeakSet();
return (key, value) => {
if (typeof value === "object" && value !== null) {
if (seen.has(value)) {
return;
}
seen.add(value);
}
return value;
};
};
const formater = function(err, args, color){
var splitter = function(stkLine, mode){
if(mode=='chrome'){
var buf = stkLine.substr(0,stkLine.indexOf(' ('));
var funcName = buf.substr(buf.lastIndexOf('.')+1);
var buf = stkLine.substr(stkLine.lastIndexOf('/')+1);
var scriptname = buf.substr(0,buf.indexOf('.js')+3);
var lincol = buf.substr(buf.indexOf(':')+1).split(':');
} else {
var funcName = stkLine.substr(0,stkLine.indexOf('@'));
if(funcName.indexOf('/')>-1) funcName = funcName.substr(0,funcName.indexOf('/'));
var buf = stkLine.substr(stkLine.lastIndexOf('/')+1);
var scriptname = buf.substr(0,buf.indexOf('.js')+3);
var lincol = buf.substr(buf.indexOf(':')+1).split(':');
}
return([funcName, scriptname, lincol]);
}
var lines = err.stack.split('\n'); var nice_stack=[];
var funcName, scriptname, lincol;
if(lines[0].toLowerCase()=='error'){ // Chrome & Edge style
[funcName, scriptname, lincol] = splitter(lines[2], 'chrome');
lines.shift();lines.shift();
for(var line of lines) nice_stack.push(splitter(line, 'chrome'));
} else { // Firefox style
[funcName, scriptname, lincol] = splitter(lines[1], 'ff');
lines.shift();
for(var line of lines) nice_stack.push(splitter(line, 'ff'));
}
var msg = args[0];
args[0] = `${args[0]} %c${funcName} @ ${scriptname} : ${lincol}`;
args.splice(1,0,'background-color:'+color+';color:#FFF;font-weight:bold;float:right;padding:2px 5px 2px 5px;border-radius:4px;');
return([args, msg, nice_stack]);
}
return {
nativeConsoleAPI:'', //noooo, the console is NOT changed... or is it ;-)
trace: oldCons.trace,
log: function(...args){
if(typeof(args[0])!='string') args.unshift('');
var nice_stack, msg;
var err = new Error();
[args, msg, nice_stack] = formater(err, args, '#070');
oldCons.log(...args);
},
info: function (...args) {
if(typeof(args[0])!='string') args.unshift('');
var nice_stack, msg;
var err = new Error();
[args, msg, nice_stack] = formater(err, args, '#070');
oldCons.info(...args);
},
warn: function (...args) {
if(typeof(args[0])!='string') args.unshift('');
var nice_stack, msg;
var err = new Error();
var msgData = (args.length>1) ? args.slice(1) : '';
[args, msg, nice_stack] = formater(err, args, '#C90');
oldCons.warn(...args);
if(config.levels.indexOf('warn')<0) return;
let data = {'level':'WARNING',
'message': msg,
'messageData' : msgData,
'user': app.User,
'timestamp':(new Date).toISOString().replace(/[A-Z]/g,' ').trim() ,
'url': document.location.toString(),
'stacktrace' : nice_stack,
};
if(config.postUrl.startsWith('https://')){
fetch(config.postUrl, {
'method': 'POST',
'body' : JSON.stringify(data, getCircularReplacer()),
'headers': {'Content-type': 'application/json; charset=UTF-8'}
})
}
appendErr(data);
},
error: function (...args) {
if(typeof(args[0])!='string') args.unshift('');
var nice_stack, msg;
var err = new Error();
var msgData = (args.length>1) ? args.slice(1) : '';
[args, msg, nice_stack] = formater(err, args, '#900');
oldCons.error(...args);
if(config.levels.indexOf('err')<0) return;
let data = {'level':'ERROR',
'message': msg,
'messageData' : msgData,
'user': app.User,
'timestamp':(new Date).toISOString().replace(/[A-Z]/g,' ').trim() ,
'url': document.location.toString(),
'stacktrace' : nice_stack,
};
if(config.postUrl.startsWith('https://')){
fetch(config.postUrl, {
'method': 'POST',
'body' : JSON.stringify(data, getCircularReplacer()),
'headers': {'Content-type': 'application/json; charset=UTF-8'}
})
}
appendErr(data);
}
};
};
+444
View File
@@ -0,0 +1,444 @@
'use strict'
/**
* PROTOCOL
Application-level payloads are always JSON and always either an action, or an event :
1. ACTIONS : are made for request-reply.
They are aimed at the dialogue between the FE (mainly messageBus core modules) and WSSGateway.
These messages are identified by the fact there is an "action" key, top level.
Example : The FE asks WSSGateway to subscribe to Redis chans :
Request:
{ "action" : "SUB", // Must be a valid wssGateway action.
"payload": ["chan1", "chan2"], // Any type required by the action
"reqid": "987654321-abcdef-123456"
}
Reply:
{ "action" : "SUB",
"payload": ["chan1", "chan9"], // probably you were already subscribed to chan9,
"reqid": "987654321-abcdef-123456" // don't have to right to chan2, but succeeded subscribing to chan1
}
Newton principle applied to WSSG:
When there is an action in one direction (request),
there is the same action in the opposite direction (reply).
When doing a request, the FE can optionally include a "reqid", with a uuid.
It then has the guarnatee that the corresponding reply will contain the same reqid.
As you can receive a reply on a particular action in any number, at any time,
this allows the FE to match one specific action request with its specific reply.
This, in turn, allows this module to provide action-promise and action-timeouts.
2. EVENTS : are any other events circulating on the bus, thus on a REDIS channel.
They are triggered by another actor on the bus, and have nothing to do with FE-WssGW dialog .
These messages are identified by the fact there is an "event" key, top level.
So far, this core-module has no use of bus-events, they are considered as applicative-level-use only.
Therefore, this module just triggers a corresponding (javascript) event, for any potential listener in the app.
{ "eventType" : "PropaSubmitted", // Any applicative thing
"payload": { // Any type depending on applicative convention for this event
"propaNumber": "123456",
"propaAcronym": "Tintin"
}
}
Will trigger a "MessageBus.PropaSubmitted" javascript event, with
"detail":
{ msg: {
eventType: "PropaSubmitted",
payload: { "propaNumber": "123456",
"propaAcronym": "Tintin"
}
},
chan: "wssGateway:chan1:subchan2",
}
--------------- Low-level, WEBSOCKET ---------------
{ "event":"REDISMSG", // low level
"payload":{ // low level
"msg":{ // low level
"eventType":"PropaSubmitted", // APP LEVEL MESSAGE = Redis payload
"payload":{ // APP LEVEL MESSAGE = Redis payload
"propaNumber": "123456", // APP LEVEL MESSAGE = Redis payload
"propaAcronym": "Tintin" // APP LEVEL MESSAGE = Redis payload
}, // APP LEVEL MESSAGE = Redis payload
sender: "N007xyz" // APP LEVEL MESSAGE = Redis payload => added by gateways !
}, // low level
"chan":"wssGateway:chan1:subchan2" // low level = Redis channel
},
}
*
* @author Nicolas Stein
* @category Core
* @subcategory Libraries
*/
class MessageBus {
/**
*
* @param {*} config
* @param {*} userInfo
*/
constructor(config, userInfo){
this.config = config
if(this.config.debug) console.log('Lauching Websocket worker...');
this.config.hostname = (('host' in this.config) && ( this.config.host!='')) ? this.config.host : document.location.hostname
this.userInfo = userInfo
this.createWorker();
this.activeSubscriptions = [];
this.promisesRegister = { };
this.bus2jsEventsRegister = []; // items: { eventType:'string', RegisteredCb: function, realCb: function }
this.whenConnectedQ = [];
this.connected = false;
}
/**
*
*/
createWorker() {
if(!this.config.pathToWorker.endsWith('.js')) this.config.pathToWorker+='.js';
this.MessageBusWorker = new Worker(this.config.pathToWorker+'?'+crypto.randomUUID());
this.MessageBusWorker.postMessage({ 'action':'start', 'config': this.config, 'userInfo': this.userInfo });
this.MessageBusWorker.onmessage = this.receiveFromWorker.bind(this);
if(this.config.debug) console.log('Websocket worker launched.');
}
/**
*
* @param {*} callBack
*/
whenConnected(callBack){
if(typeof(callBack) != 'function') return;
if(this.connected) callBack();
else this.whenConnectedQ.push(callBack);
}
/**
*
* @param {*} timeout
* @returns {Promise}
*/
whenConnectedP(timeout=0){
return(
new Promise((resolve,reject) => {
this.whenConnected(resolve)
if(timeout>0) setTimeout(reject, timeout)
})
)
}
/**
*
* @param {*} callBack
*/
ifConnected(callBack){
if(typeof(callBack) != 'function') return;
if(this.connected) callBack();
}
/**
*
*/
executewhenConnectedQ() { for(var callBack of this.whenConnectedQ) callBack(); }
/**
* Request-reply an action from the WSSGateway
* This is a pure websocket exchange between client and WssGW.
* This request does not pass through the (Redis) bus.
* This method gives (and resolves) a promise, taking care of all lower-level details
*/
requestWssGwAction(action, payload=null, timeOut=5000){
if(!action) return;
let request = {'action':action, 'payload':payload};
request.reqid = crypto.randomUUID();
return(new Promise((resolve, fail) => {
let timeOutID = setTimeout(() => {
fail(`Timeout (>${timeOut}ms) for action ${action}`);
if(this.promisesRegister[request.reqid]) delete(this.promisesRegister[request.reqid])
}, timeOut);
this.promisesRegister[request.reqid] = [resolve, fail, timeOutID];
this.MessageBusWorker.postMessage(request);
}));
}
/**
* Request-reply an action towards an agent on the bus (normally infra, like HttpGw)
* This request will pass through the (Redis) bus.
* The reply will come on my own user notification channel.
* This method gives (and resolves) a promise, taking care of all lower-level details
*/
requestBusAction(chan, action, payload=null, timeOut=5000){
if(!action) return;
let request = {'action':action, 'payload':payload};
request.reqid = crypto.randomUUID();
return(new Promise((resolve, fail) => {
let timeOutID = setTimeout(() => {
fail(`Timeout (>${timeOut}ms) for action ${action}`);
if(this.promisesRegister[request.reqid]) delete(this.promisesRegister[request.reqid])
}, timeOut);
this.promisesRegister[request.reqid] = [resolve, fail, timeOutID];
if(!chan.startsWith(this.config.frontBusPrefix)) chan = this.config.frontBusPrefix+chan
this.send(chan, JSON.stringify(request))
}));
}
/**
* Request-reply an action towards Midas
* This request will pass through the (Redis) bus.
* The reply will come on my own user notification channel.
* This method gives (and resolves) a promise, taking care of all lower-level details
*/
requestMidasAction(chan, action, data=null, timeOut=5000){
if(!action) return;
let request = {payload: {'action':action, 'data':data}}
request.reqid = crypto.randomUUID();
return(new Promise((resolve, fail) => {
let timeOutID = setTimeout(() => {
fail(`Timeout (>${timeOut}ms) for action ${action}`);
if(this.promisesRegister[request.reqid]) delete(this.promisesRegister[request.reqid])
}, timeOut);
this.promisesRegister[request.reqid] = [resolve, fail, timeOutID];
if(!chan.startsWith(this.config.frontBusPrefix)) chan = this.config.frontBusPrefix+chan
this.send(chan, JSON.stringify(request))
}));
}
/**
*
* @param {*} chan
* @param {*} eventType
* @param {*} eventPayload
*/
sendEvent(chan, eventType, eventPayload){
if(!chan.startsWith(this.config.frontBusPrefix)) chan = this.config.frontBusPrefix+chan
this.send(
chan,
JSON.stringify({ eventType: eventType,
payload: eventPayload
})
)
}
/**
*
* @param {*} chan
* @param {*} msg
*/
send(chan, msg){
// You can publish to an unsubscribed chan, userchans are the best example !
// if(this.activeSubscriptions.indexOf(chan)<0) return;
var request = {'action':'PUB', 'payload': { 'chan':chan, 'msg': msg}};
this.MessageBusWorker.postMessage(request);
}
/**
* Registers a bus event, filtering on allowed incoming chans.
* => Callback takes arguments (chan, eventType, payload)
* where chan is the actual chan that carried the event eventType
*
* Filtering is important because you could have evenType = 'update',
* arriving on chans like 'dataChange:proposal' and 'dataChange:organisation' (thus for different actions).
* Besides, you don't want to react for example on 'growl' if it's arriving on
* some chan publishable by another user and misused by him.
*
* @param {string} eventType
* @param {Array} filterChans Array of allowed chans (string). Globbing with '*' is allowed.
* @param {*} callback
*/
addBusListener(eventType, filterChans, cb, scope=''){
let realCb = (e => {
let realChan = e.detail.chan
if(filterChans.every(filterChan => (!this.chanMatch(realChan, filterChan)))) return
cb(realChan, e.detail.payload, e.detail.sender)
})
let realEventType = 'MessageBus.event.'+eventType
app.events.addEvent(realEventType, realCb, 'MessageBus'+scope)
this.bus2jsEventsRegister.push({
eventType: eventType,
cb: cb,
realEventType : realEventType,
realCb: realCb
})
}
/**
* De-registers bus event(s)
* If several events of the same type, same calback, then they are all whiped
*/
removeBusListener(eventType, cb, scope=''){
let toKick = this.bus2jsEventsRegister.filter(
item => ((item.eventType==eventType) && (item.cb==cb))
)
for(let kickItem of toKick){
app.events.removeEvent(kickItem.realEventType, kickItem.realCb, 'MessageBus'+scope)
}
}
/**
* Subscribe to channels
*
* @param {object} channels
* @returns {object}
*/
subscribe(channels){
return(this.requestWssGwAction('SUB', channels))
}
/**
* Unsubscribe from channels
*
* @param {*} channels
* @returns {object}
*/
unSubscribe(channels) { return(this.requestWssGwAction('UNSUB', channels)) }
/**
* Get current subscriptions list
* @returns {object}
*/
subscriptionsList() { return(this.requestWssGwAction('SUBLST')) }
/**
* Get channel history
*
* @param {*} channels
* @returns {object}
*/
chanHistory(channel, from, to){
let payload = {
channel: channel,
from: from
}
if(to) payload['to'] = to
return(this.requestWssGwAction('CHANHIST', payload))
}
/**
* Helper method to match a chan with globbing
*
* @param {string} myChan (no glob)
* @param {string} targetChan (possible glob)
* @returns {boolean}
*/
chanMatch(myChan, targetChan) {
let re = new RegExp('^'+targetChan.replace(/\*/g,'(.+)')+'$','g')
return(myChan.match(re)!=null)
}
/**
*
* @param {Event} event
*/
receiveFromWorker(e) {
var workermsg = e.data;
if('event' in workermsg){
// event "ReceiveFromServer" is the general case of a message from server, found in data, with its own struct.
// other type og event are generated by the worker, about the connection
switch(workermsg.event){
case 'ReceiveFromServer':
this.receiveFromServer(JSON.parse(workermsg.data));
break;
case 'connected':
this.connected = true;
if(this.config.debug) console.log('received connected event from worker !');
this.executewhenConnectedQ();
app.events.trigger('MessageBus.Connected');
break;
case 'closed':
if(this.config.debug) console.log('received closed event from worker!');
this.activeSubscriptions = [];
this.callBacksRegister = { };
this.whenConnectedQ = [];
this.connected = false;
app.events.trigger('MessageBus.Closed');
break;
default:
if(this.config.debug) console.warn('Unknown Websocket Worker message:', workermsg);
}
}
}
/**
*
* @param {object} data
* @param {string} data.action Possible values: 'SUB', 'SUBLST', ...
* @param {string} [data.reqid]
* @param {object} [data.payload] response payload
* @param {object} [data.err] response error
* @param {boolean} [data.success]
*/
receiveFromServer(srvdata) {
// See protocol reminder comment at the bottom
if('action' in srvdata){ // Reply to a request
let action = srvdata.action;
let payload = ('payload' in srvdata) ? srvdata.payload : null;
// Piggyback on the results of some actions for this module internal use
switch(action){
case 'SUB':
if(this.activeSubscriptions.indexOf(payload)<0) this.activeSubscriptions = this.activeSubscriptions.concat(payload);
break;
case 'SUBLST':
if(this.activeSubscriptions.indexOf(payload)<0) this.activeSubscriptions = this.activeSubscriptions.concat(payload);
break;
}
app.events.trigger('MessageBus.anyAction', srvdata);
} else { // Low-level event : Redis Event, contrary to requ/reply with wssGateway, or other later
if(('event' in srvdata) && (srvdata.event == 'REDISMSG')){
var payload = ('payload' in srvdata) ? srvdata.payload : null;
if(payload && payload.msg && (payload.msg.eventType || payload.msg.action)) {
if(payload.msg.eventType){
var eventType = payload.msg.eventType;
app.events.trigger('MessageBus.event.'+eventType, {
chan: payload.chan,
sender: payload.msg.sender,
eventType: payload.msg.eventType,
payload: payload.msg.payload,
});
} else if(payload.msg.action && payload.msg.reqid) {
let reqid = payload.msg.reqid;
let action = payload.msg.action;
let actionPayload = ('payload' in payload.msg) ? payload.msg.payload : null;
let err = ('err' in payload.msg) ? payload.msg.err : null;
let success = payload.msg.success;
if(reqid in this.promisesRegister) {
clearTimeout(this.promisesRegister[reqid][2]); // Stop timeout timer
if(success) this.promisesRegister[reqid][0](actionPayload); // resolve
else this.promisesRegister[reqid][1](`MsgBus action "${action}" failed.\nError: ${err}`); // Fail
}
}
app.events.trigger('MessageBus.anyMessage', {
chan: payload.chan,
msg : payload.msg,
});
} else if(payload && payload.bmsg){
app.events.trigger('MessageBus.promiscuousMessage', { // Repill msg : decapsulate & use spcific event
chan: payload.bmsg.chan,
msg : payload.bmsg.msg,
});
}
else {
console.warn('Weird bus message (discarted) :', srvdata)
}
}
}
// For request-reply, settle promise
if(srvdata.reqid && (srvdata.reqid in this.promisesRegister)) {
let payload = ('payload' in srvdata) ? srvdata.payload : null;
let err = ('err' in srvdata) ? srvdata.err : null;
let success = srvdata.success;
clearTimeout(this.promisesRegister[srvdata.reqid][2]); // Stop timeout timer
if(success) this.promisesRegister[srvdata.reqid][0](payload); // resolve
else this.promisesRegister[srvdata.reqid][1](`MsgBus action failed.\nError: ${err}`); // Fail
}
}
}
app.registerClass('MessageBus', MessageBus);
+223
View File
@@ -0,0 +1,223 @@
'use strict'
// Remember : the whole app context is in another parallel & inacessible universe !
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.substr(0,8)+'-'+uuid.substr(10,4)+'-'+uuid.substr(14,4)+'-'+uuid.substr(18,4)+'-'+uuid.substr(22));
}
}
/**
*
*
* @author Nicolas Stein
* @category Core
* @subcategory Libraries
* @requires MessageBus
*/
class MessageBusWorker {
/**
*
* @param {*} config
* @param {*} userInfo
*/
constructor(config, userInfo){
this.config = config;
this.userInfo = userInfo;
this.wsurl = this.config.protocol+this.config.hostname;
if(('port' in this.config) && (this.config.port!='')) this.wsurl += ':'+this.config.port;
this.wsurl += this.config.path ;
this.keepAlive = true;
this.curReconnectTime = 0;
this.ConnectTimeout = null
this.token = false
this.stateMachine = 'DISCONNECTED'
this.noReconnect = false
// 'DISCONNECTED'
// -> 'LOGIN' (receive challenge & answer to it)
// -> 'READY' (received logged=true)
this.getToken()
}
/**
*
*/
getToken() {
if(!this.config.devotpToken){
fetch(this.config.tokenUrl+'?'+crypto.randomUUID(),{
credentials: 'include'
})
.then(response => response.json(), (err => {console.log('ERROR IN FETCH:',err)}))
.then(data => {
if(data.success && data.payload && data.payload.token) {
this.token = data.payload.token
if(this.config.debug && ''=='sensitive-even-for-debug') console.log(`Received Token : ${this.token}`)
this.connect();
} else {
console.warn('Could not get messagebus token !')
//TODO retry once in a while / integrate in the whole connect process
// to be part of retrials...
}
})
} else {
console.warn('!!! Using dev token for bus !!!')
this.token = this.config.devotpToken
this.connect();
}
}
/**
*
*/
connect(){
this.socket = new WebSocket(this.wsurl);
this.ConnectTimeout = setTimeout(() => {
if((this.socket) && (close in this.socket)) this.socket.close(null);
}, this.config.connectTimeout*1000);
this.socket.onopen = this.WSonOpen.bind(this);
this.socket.onmessage = this.WSonMessage.bind(this);
this.socket.onclose = this.WSonClose.bind(this);
this.socket.onerror = this.WSonError.bind(this);
}
/**
*
* @param {*} data
*/
clientActionDispatch(data){
if(this.socket.readyState != 1) {
var state = [ 'Connecting', '', 'Closing', 'Closed'];
console.warn(`Attempt to send to ${state[this.socket.readyState]} Websocket !`);
return;
}
if(typeof(data)!='string') data=JSON.stringify(data);
this.socket.send(data);
}
/**
*
* @param {*} e
*/
WSonOpen(e){
this.stateMachine = 'LOGIN'
clearTimeout( this.ConnectTimeout);
console.log('Websocket connection established');
this.curReconnectTime = 0;
}
/**
*
* @param {*} challenge
*/
async login(challenge) {
let data = new TextEncoder().encode(this.token+challenge)
let bytesBuf = await crypto.subtle.digest("SHA-512", data)
let arrayBuf = Array.from(new Uint8Array(bytesBuf))
let response = arrayBuf.map((b) => b.toString(16).padStart(2, "0")).join("")
if(this.config.debug && ''=='sensitive-even-for-debug') console.log(`Answering to challenge, with userinfo:`, response, this.userInfo)
this.clientActionDispatch({'action':'LOGIN', 'userInfo': this.userInfo , 'otp': response});
}
/**
*
* @param {*} e
*/
WSonMessage(e){
if(e.data.toLowerCase()=='unauthorized'){ // Do not spam if session is lost
this.noReconnect = true
if(this.config.debug) console.log(`Received MSG unauthorized !?`)
return;
}
// We're supposed to receive JSON only !
try{
var data = JSON.parse(e.data);
} catch(e){
console.warn('WSS: Received garbage :'+e.data);
return;
}
//if(this.config.debug) console.log(`Received MSG (in state:${this.stateMachine}) :`, data, this.stateMachine)
// LOGIN messages
if(this.stateMachine == 'LOGIN'){
if(data.action!='LOGIN') { // Non LOGIN messages in a LOGIN state are garbage
console.warn('WSS: Non-login message in a LOGIN state',data.action)
return
}
if(data.challenge) { // step1: challenge to reply
if(this.config.debug && ''=='sensitive-even-for-debug') console.log(`Got challenge ${data.challenge}...`)
this.login(data.challenge)
return
} else if(data.logged===true){ // step2 logged !
if(this.config.debug) console.log(`Logged !`)
this.stateMachine = 'READY'
postMessage({'event': 'connected' });
return
} else if(data.logged===false){ // step2 bad login !
this.noReconnect = true
console.warn('WSS-Login: challenge-response refused. (session lost?)')
return
}
}
if((data.action=='PING') && this.keepAlive){ // Keep Alive is managed here
this.clientActionDispatch({'action':'PONG'});
return
}
// All other messages are the upper-layer's business !
postMessage({'event': 'ReceiveFromServer', 'data':e.data});
}
/**
*
* @param {*} e
*/
WSonClose(e){
clearTimeout( this.ConnectTimeout);
console.warn(`Websocket connection has closed ! [${(new Date()).toISOString()}]`);
postMessage({'event': 'closed' });
this.socket.close();
if(this.noReconnect) return
var reconnectTime = parseFloat(this.config.autoReconnect);
var reconnectTimeFactor = parseFloat(this.config.autoReconnectTimeFactor);
var reconnectTimeMax = parseFloat(this.config.autoReconnectTimeMax);
var reconnectJitterPercent = parseFloat(this.config.autoReconnectJitterPercent);
if( (!isNaN(reconnectTime)) && (!isNaN(reconnectTimeFactor)) && (!isNaN(reconnectTimeMax)) && (!isNaN(reconnectJitterPercent)) ) {
if(this.curReconnectTime==0) this.curReconnectTime = reconnectTime;
else {
this.curReconnectTime *= reconnectTimeFactor;
if(this.curReconnectTime>reconnectTimeMax) this.curReconnectTime = reconnectTimeMax;
}
var rjit = (Math.random()*reconnectJitterPercent)-(reconnectJitterPercent/2);
this.curReconnectTime += (this.curReconnectTime*(rjit/100));
// Reconnect in curReconnectTime (=>getToken THEN connect)
setTimeout(this.getToken.bind(this), Math.floor(1000*this.curReconnectTime));
}
}
/**
*
* @param {*} e
*/
WSonError(e){
//console.warn('Websocket error:', e.message);
}
}
var msgbus = null;
onmessage = (e) => { // message from client
if (e.data.action=='start') {
if(!msgbus) msgbus = new MessageBusWorker(e.data.config, e.data.userInfo);
} else {
if(msgbus) msgbus.clientActionDispatch(e.data);
}
}
+48
View File
@@ -0,0 +1,48 @@
'use strict'
/**
* @author Nicolas Stein
* @category Core
* @subcategory Libraries
* @requires MessageBus
*/
class Ptp {
/**
*
* @param {*} config
* @param {*} userInfo
*/
constructor(config, userInfo){
this.config = config
this.userInfo = userInfo
if(!app.MessageBus) {
throw('Ptp depends upon MessageBus which is absent !')
}
}
/* uses msgBus to check get remoteUser online browsers
Remote UIs let the user accept the invite (and it selects which browser to use for Ptp)
Accepting the invite is done by sending back his browserId
Remote can only accept invite if Ptp is enabled, & userAgent is compatible.
Resolves to null or browserId
*/
invite(remoteUser){
return(new Promise(
))
}
/*
uses msgBus to make the WRTC initial handshake
*/
wrHandshake(remoteBrowserId){
return(new Promise(
))
}
}
app.registerClass('Ptp', Ptp);
+557
View File
@@ -0,0 +1,557 @@
/**
____ _____ __ __ ____ ____ ____
( _ \ ( _ )( )( )(_ _)( ___)( _ \
) _/ )(_)( )(__)( )( )__) ) /
(_)\_) (_____)(______) (__) (____)(_)\_) for SPARC
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 <https://www.gnu.org/licenses/>.
* This class contains the SPA router mechanics.<br>
* Once instanciated with a set of top-level routes, just launch instance.route().<br>
*
* @category Core
* @subcategory Libraries
*/
class Router {
/**
* @typedef RouteNode
* @property {string} url
* @property {string} role
* @property {string} controller
* @property {string} [method]
*/
/**
*
* @param {object} args - object defining top-level routes
* @param {object} args.routes - object defining top-level routes
* @param {string|function} args.role - user role, used for selecting most apropriate route (if function: called at each re-routing)
* @param {function} args.callback - Called when everything a route has been executed
* @param {string} args.origin - [Experimental] - when router is not master of the domain root : process only URLs that begin with it.
* @param {string} args.controllersPath - Location of application's scripts
* @param {object} args.defaults - default values
*/
constructor(args) {
this.defaults = args.defaults;
if(args.hasOwnProperty('controllersPath') && (args.controllersPath!='')) this.defaults.controllersPath = args.controllersPath;
if(args.hasOwnProperty('modelsPath') && (args.modelsPath!='')) this.defaults.modelsPath = args.modelsPath;
if(args.hasOwnProperty('viewsPath') && (args.viewsPath!='')) this.defaults.viewsPath = args.viewsPath;
this.defaults.origin = args.hasOwnProperty('origin') && (args.origin!='') ? args.origin : (new URL(document.location)).origin;
this.ControllerInstances = {};
this.ControllerConfigs = {};
this.httpErrorRoute = null;
this.routes = this.cleanupRoutes(args.routes);
this.roles = args.roles;
this.callback = args.callback;
this.routeReady = false;
this.merged_ctrl_routes = [];
this.currentRoute = null;
document.addEventListener('click', this.onGlobalClick.bind(this));
window.addEventListener('popstate', this.bckFwdInterceptor.bind(this));
}
onGlobalClick(event) {
let target = event.target;
let notfound = true;
while(target && target.tagName && (target.tagName.toUpperCase() != 'BODY') && notfound){
if(target.tagName.toUpperCase() == 'A'){
notfound = false;
this.linksInterceptor(target, event);
} else target = target.parentNode;
}
}
bckFwdInterceptor(e) { this.route(); }
linksInterceptor(target, e){
if(target.hasAttribute('noroute')) return
if(target.href.substr(0,this.defaults.origin.length) == this.defaults.origin) { //internal
e.preventDefault()
history.pushState({}, '', document.location)
history.replaceState(null, '', this.cleanupUrl(target.href).pathname)
this.route()
}
}
cleanupUrl(target) {
let url = new URL(target || document.location, this.defaults.origin);
url.pathname = url.pathname.replace(/\/+/g,'/');
return(url);
}
route(forcedUrl, forcedParams) {
if(forcedUrl) history.replaceState(null, null, forcedUrl);
let routeinfo = this.getRoute(forcedUrl, forcedParams || {});
let [currentRoute, ctrlPath, ctrlClass, ctrlMethod, params, exturl] = routeinfo
if(exturl && exturl.match(/\w{2,6}:\/\//)) {
if(params) exturl += '?' +(new URLSearchParams(params).toString());
document.location = exturl;
this.routeReady = true;
return;
} else if(exturl && (exturl != '')) { // Internal redirect
history.replaceState(null, '', exturl);
this.route();
} else {
this.execRoute(...routeinfo);
}
}
execRoute(currentRoute, ctrlPath, ctrlClass, ctrlMethod, params, exturl){
this.routeReady = false;
// Start by making sure we have the MasterController (aka "mc") loaded, if not, first, load it!
let mcPath = app.config.router.controllersPath;
let mcLoader;
if(!(app.config.router.masterController in app.LoadedClasses)){
mcLoader = app.LoadedClasses.Assets.loadJson({
'path': mcPath,
'name': app.config.router.masterController+'.json',
}).then((tcConfig) => {
if(typeof(tcConfig)!='undefined') {
tcConfig.routes=[]; // no subroutes for the masterController (use baseRoutes instead)
// Avoid controllerConfigLoaded (made for normal ctrl, but merge config directly)
this.mergeControllerConfig(currentRoute, mcPath, app.config.router.masterController, null, {} , tcConfig);
// return the autoLoadController Promise, so the "then" below depends on ctrl loaded (not config loaded)
return(this.autoLoadController(currentRoute, mcPath, app.config.router.masterController, null, {}));
} else {
console.error('Problem loading Master-Controller ! (could not load its config)');
return(new Promise((resolve,fail) => { fail(); }));
}
});
} else { mcLoader = new Promise((resolve) => { resolve(); }) }
mcLoader.then( () => {
// Get controller config file (add controllersPath only to json, for the script it is done by getRoute)
app.LoadedClasses.Assets.loadJson({
'path': ctrlPath,
'name': ctrlClass+'.json',
})
.then( (config) => {
if(typeof(config)!='undefined') {
this.controllerConfigLoaded(currentRoute, ctrlPath, ctrlClass, ctrlMethod, params, exturl, config);
} else {
let err = 'Could not load configuration of controller '+ctrlClass+' !!';
this.redirectToHttpErrorRoute(err);
}
});
});
}
controllerConfigLoaded(currentRoute, ctrlPath, ctrlClass, ctrlMethod, params, exturl, config) {
config = config || { // Cool attitude if config not found or not clean json
routes:[],
dependencies:[],
AssetsDependencies:{}
};
this.mergeControllerConfig(currentRoute, ctrlPath, ctrlClass, ctrlMethod, params, config);
// Recompute route now that there are some sub-routes, if something changed we need to recurse
// If nothing changed, then we're good to load, instanciate & call !
let newCurrentRoute, newCtrlPath, newCtrlClass, newCtrlMethod, newParams;
[newCurrentRoute, newCtrlPath, newCtrlClass, newCtrlMethod, newParams, exturl] = this.getRoute(currentRoute.forcedUrl, currentRoute.forcedParams);
if(exturl) { //Do not recur if we stumbbled on a exturl, just go to it
this.route(exturl, null)
} else if(newCurrentRoute.url!=currentRoute.url){
this.execRoute(newCurrentRoute, newCtrlPath, newCtrlClass, newCtrlMethod, newParams, exturl);
} else { // the else makes sure we autoLoad only at the bottom-level of the recursion
if(typeof(this.ControllerInstances[ctrlClass])=='object') {
// Non-persistent controllers were once were here, but not usefull, removed
this.launchControllerMethod(currentRoute, ctrlPath, ctrlClass, ctrlMethod, params);
} else { // Unknown ctrl : load it !
this.autoLoadController(newCurrentRoute, newCtrlPath, newCtrlClass, newCtrlMethod, newParams);
}
}
}
mergeControllerConfig(currentRoute, ctrlPath, ctrlClass, ctrlMethod, params, config){
this.ControllerConfigs[ctrlClass] = config
// Carefull not to remerge twice the same config (otherwise url concats will go bzurg)
if(this.merged_ctrl_routes.indexOf(ctrlClass)>-1) return;
this.merged_ctrl_routes.push(ctrlClass);
// Add sub routes, except for masterctrl
if((ctrlClass!= app.config.router.masterController) || ('routes' in config)){
let newroutes = this.cleanupRoutes(config.routes);
for(let newroute of newroutes){
if(currentRoute.url=='!defaultroute') continue;
let fullurl = (currentRoute.url+'/'+newroute.url).replace(/\/+/g,'/');
newroute.url = fullurl;
this.routes.push(newroute);
}
}
if(!Loader.Dependencies.hasOwnProperty(ctrlClass)) Loader.Dependencies[ctrlClass] = [];
// Add controller dependencies (not views, not)
if('controllerDependencies' in config){
for(var script of config.controllerDependencies){
if(script.substring(0,4)!='http') Loader.Dependencies[ctrlClass].push(('/app/'+script).replace(/\/+/g,'/'))
else Loader.Dependencies[ctrlClass].push(script)
}
}
// add models as controller dependencies with adapted path
if('models' in config){
for(var dep of config.models){
if(typeof(dep) == 'string') {// just add model as dependency of controller, with correct path
let model = (this.defaults.modelsPath+'/'+dep).replace(/\/+/g,'/');
Loader.Dependencies[ctrlClass].push(model);
} else if(typeof(dep) == 'object'){ // This model depends on other models
let model = (this.defaults.modelsPath+'/'+dep.model).replace(/\/+/g,'/');
// add model as dependency of controller
Loader.Dependencies[ctrlClass].push(model);
// add sub-dependencies without dups & with correct path
if(!Loader.Dependencies.hasOwnProperty(model)) Loader.Dependencies[model] = [];
for(let submodel of dep.dependencies){
if(Loader.Dependencies[model].indexOf(submodel)<0) {
Loader.Dependencies[model].push((this.defaults.modelsPath+'/'+submodel).replace(/\/+/g,'/'));
}
}
}
}
}
// add views as controller dependencies with adapted path
if('views' in config){
for(var dep of config.views){
if(typeof(dep) == 'string') {// just add view as dependency of controller, with correct path
let view = (this.defaults.viewsPath+'/'+dep).replace(/\/+/g,'/');
Loader.Dependencies[ctrlClass].push(view);
} else if(typeof(dep) == 'object'){ // This view depends on other views
let view = (this.defaults.viewsPath+'/'+dep.view).replace(/\/+/g,'/');
// add view as dependency of controller
Loader.Dependencies[ctrlClass].push(view);
// add sub-dependencies without dups & with correct path
if(!Loader.Dependencies.hasOwnProperty(view)) Loader.Dependencies[view] = [];
for(let subview of dep.dependencies){
if(Loader.Dependencies[view].indexOf(subview)<0) {
Loader.Dependencies[view].push((this.defaults.viewsPath+'/'+subview).replace(/\/+/g,'/'));
}
}
}
}
}
// Remove dups (maybe there was existing stuff before we got here)
Loader.Dependencies[ctrlClass] = Array.from(new Set(Loader.Dependencies[ctrlClass]));
Loader.AssetsDependencies[ctrlClass] = config.assets;
}
autoLoadController(currentRoute, ctrlPath, ctrlClass, ctrlMethod, params){
let scriptFp = (ctrlPath+'/'+ctrlClass).replace(/\/+/g,'/');
let deps = {}; deps[scriptFp] = Loader.Dependencies[ctrlClass];
return (
Loader.loadScripts({
'scripts':[scriptFp],
'dependencies':deps,
}).then(
() => {
this.controllerLoaded(currentRoute, ctrlPath, ctrlClass, ctrlMethod, params)
},
(error) => { // Loading of the route failed => Try to reroute to defaultroute
let err = 'Loading of the class "'+ctrlClass+'" Failed !';
console.error(err);
if(currentRoute != this.httpErrorRoute) this.redirectToHttpErrorRoute(err);
this.routeReady = true;
}
)
);
}
registerController(ctrlClass){
try {
this.ControllerInstances[ctrlClass] = new app.LoadedClasses[ctrlClass]();
} catch(e) { console.error(`"${e}" instantiating controller ${ctrlClass} `); } ;
}
controllerLoaded(currentRoute, ctrlPath, ctrlClass, ctrlMethod, params){
this.registerController(ctrlClass);
if(ctrlClass != app.config.router.masterController) {
let mTemplate
if('template' in currentRoute)
mTemplate = currentRoute.template;
else
mTemplate = app.config.router.defaultMasterTemplate;
// Launch the useTemplate and wait for it to be ready before launching the method.
this.ControllerInstances[app.config.router.masterController].useTemplate(mTemplate).then(
() => {
this.launchControllerMethod(currentRoute, ctrlPath, ctrlClass, ctrlMethod, params);
}
);
}
}
launchControllerMethod(currentRoute, ctrlPath, ctrlClass, ctrlMethod, params){
if((ctrlClass in this.ControllerInstances) && (typeof(this.ControllerInstances[ctrlClass][ctrlMethod])=='function')) {
this.currentRoute = currentRoute
this.currentRoute.realUrl = new URL(document.location.href).pathname
this.ControllerInstances[ctrlClass][ctrlMethod]({
'currentRoute': currentRoute,
'params': params
});
this.routeReady = true;
if(typeof(this.callback)=='function') this.callback(ctrlClass,ctrlMethod,params);
} else {
let err;
if(ctrlClass in this.ControllerInstances){
err = `Could not find method "${ctrlMethod}" in class "${ctrlClass}"`;
} else {
err = `Controller "${ctrlClass}" not instantiated !`;
}
console.error(err);
if(currentRoute != this.httpErrorRoute)
this.redirectToHttpErrorRoute(err);
this.routeReady = true;
}
}
redirectToHttpErrorRoute(err=''){
let ctrlPath = this.httpErrorRoute.controller.substr(0,this.httpErrorRoute.controller.lastIndexOf('/')+1);
ctrlPath = (this.defaults.controllersPath+'/'+ctrlPath).replace(/\/+/g,'/');
let ctrlClass = this.httpErrorRoute.controller.substr(this.httpErrorRoute.controller.lastIndexOf('/')+1);
let ctrlMethod =this.httpErrorRoute.method;
app.LoadedClasses.Assets.loadJson({
'path': ctrlPath,
'name': ctrlClass+'.json',
}).then( (config) => {
this.mergeControllerConfig(this.httpErrorRoute, ctrlPath, ctrlClass, ctrlMethod, {'error' : err}, config)
this.autoLoadController(this.httpErrorRoute, ctrlPath, ctrlClass, ctrlMethod, {'error' : err});
});
}
getRoute(forcedUrl, forcedParams) {
let pathname = this.cleanupUrl(forcedUrl).pathname;
let bestroute = {'score':0, 'route':{}, 'parts':'', 'extractedParams':{}, 'gibberish':'', idx:0 };
let idx = 0;
let myRoles = (typeof(this.roles)=='function') ? this.roles() : this.roles ;
for(let route of this.routes){
route['forcedUrl'] = forcedUrl;
route['forcedParams'] = forcedParams;
let intersect = route['role'].filter(v => (myRoles.indexOf(v)>-1));
if((route['role']!='*') && (intersect.length==0)) continue;
let [score, parts, paramvals, gibberish] = this.matchUrl(pathname, route);
// make sure a role specific route wins over a '*' role route (if already match, thus score>0, not to interfere with defaultroute).
if((score>0) && (route['role']!='*') && (intersect.length>0)) {
score++;
}
// Best route wins, and when exaequo, childe-route (embed) wins over a parent-route
if( (score > bestroute.score) || ((score == bestroute.score) && (idx > bestroute.idx)) ) {
bestroute = {'score':score, 'route':route, 'parts':parts, 'extractedParams':paramvals, 'gibberish':gibberish, idx:idx };
}
idx++;
}
if(bestroute['score']==0){ // no match => return default route
if(this.httpErrorRoute)
bestroute = {
'score': 0,
'route': this.httpErrorRoute,
'parts': [],
'extractedParams': {'error' : 'No route found for this URL!'} ,
'gibberish': pathname,
'idx': 0
};
else {
console.error('No matching route for this url, and no default route defined!');
return([null,'', '', '', {}]);
}
}
let ctrlPath, ctrlClass, ctrlMethod, methParams, exturl;
if(bestroute.route.hasOwnProperty('controller')){ // Normal MVC route
ctrlPath = bestroute.route.controller.substr(0,bestroute.route.controller.lastIndexOf('/')+1);
ctrlPath = (this.defaults.controllersPath+'/'+ctrlPath).replace(/\/+/g,'/');
ctrlClass = bestroute.route.controller.substr(bestroute.route.controller.lastIndexOf('/')+1);
if(bestroute.route.hasOwnProperty('method') && (bestroute.route.method!='')){ // predefined Method in config
ctrlMethod =bestroute.route.method;
} else { // no predefined Method,
// unused stuff in url ? use last url part
if(bestroute.score >= 2) ctrlMethod = bestroute.parts[bestroute.parts.length-1];
else ctrlMethod = 'index'; // nothing left in url ? default to index
}
if(!bestroute.route.hasOwnProperty('extractedParams')) bestroute.params = {};
methParams = {...bestroute.extractedParams, ...bestroute.route.params, ...forcedParams};
exturl = '';
} else { // External route
exturl = bestroute.route.exturl;
}
return([bestroute.route, ctrlPath, ctrlClass, ctrlMethod, methParams, exturl]);
}
cleanupRoutes(routes){
const regex = /[\/:][^\/:]+/g;
const paramregex = /^:(\w+)(\(.*\))?/;
const pathregex = /^[-\/\w]+$/;
const roleregex = /^[-\w]+$/;
let cleanroutes = [];
for(let route of routes){
let keepit = true;
if(!route.hasOwnProperty('url')) {
console.warn('Missing url in route definition, route ignored ! ',route);
keepit = false;
} else {
if(route.url!='!defaultroute'){
let routeparts = route.url.match(regex);
if(routeparts===null) routeparts = ['/']; //Happens for empty of slah-only url; (or maybe cataclysmic ones)
for(let routefrag of routeparts) {
if(routefrag.charAt(0)=='/'){
if(!(routefrag.match(pathregex))) {
console.warn('Bad url in route definition (forbidden character in path), route ignored ! ',route);
keepit = false;
}
} else {
if(!(routefrag.match(paramregex))) {
console.warn('Bad url in route definition (forbidden character in param), route ignored ! ',route);
keepit = false;
}
}
}
} else {
this.httpErrorRoute = route;
}
}
if(!route.hasOwnProperty('role')) {
console.warn('Missing role in route definition, route ignored ! ',route);
keepit = false;
} else {
if(typeof(route.role)=='string') route.role = [route.role];
if(route.role!='*'){
for(let role of route.role){ // also works if string (letter by letter but ok)
if(!(role.match(roleregex))) {
console.warn('Bad role in route definition (forbidden character), route ignored ! ',route);
keepit = false;
}
}
}
}
if( (!route.hasOwnProperty('controller')) && (!route.hasOwnProperty('exturl')) ) {
console.warn('Missing one of controller / exturl in route definition, route ignored ! ',route);
keepit = false;
} else if(route.hasOwnProperty('exturl')) {
// don't touch it !
} else if(!(route.controller.match(pathregex))) {
console.warn('Bad controller path in route definition (forbidden character), route ignored ! ',route);
keepit = false;
}
if(keepit) cleanroutes.push(route);
}
return(cleanroutes);
}
matchUrl(urlpath, route){
const regex = /[\/:][^\/:]+/g;
const paramregex = /:(\w+)(\(.*\))?/;
if((urlpath=='/') && (route.url=='/')) return([1, '/', {}, []]);
let urlpaths = urlpath.substr(1).split('/');
let nbmatch = 0;
let gibberish = '';
let routeparts;
routeparts = route.url.match(regex);
let parts = []; let params = {};
if(!routeparts) return([0, [], {}, '']);
for(let routefrag of routeparts) {
if(nbmatch>(urlpaths.length-1)) {
return([0, [], {}, '']);
}
if(routefrag.charAt(0)=='/'){ // url frag
if(routefrag!='/') routefrag = routefrag.substr(1);
if(routefrag!=urlpaths[nbmatch]){
return([0, [], {}, '']);
}
parts.push(urlpaths[nbmatch]);
} else { // param
if(urlpaths[nbmatch]=='') return([0, [], {}, '']); // no '//' where param expected
let parampattern = routefrag.match(paramregex);
if(!parampattern) {
console.warn('Bad parameter syntax in routes definition : '+routefrag+'(ignored)');
} else {
let [x, parname, parvalidation] = parampattern
if(parvalidation){ //Validate with given regex
let pvrx = RegExp('^'+parvalidation.substr(1, parvalidation.length-2)+'$');
if(urlpaths[nbmatch].match(pvrx)) params[parname] = urlpaths[nbmatch];
else return([0, [], {}, '']); // parameter with missed validation
} else {
params[parname] = urlpaths[nbmatch]; // take without validation
}
}
}
nbmatch++;
}
let pointer;
// Still have stuff in url, and no predefined method, so take meth from url
if( (nbmatch<urlpaths.length) && ((!route.hasOwnProperty('method') || (route.method==''))) && (urlpaths[nbmatch]!='') ){
parts.push(urlpaths[nbmatch]);
pointer = nbmatch+1; // Pointer should advance as we've taken this part of the url, but does not count in score !
} else pointer = nbmatch;
if(pointer<urlpaths.length){
gibberish = urlpaths.splice(pointer).join('/');
}
return([nbmatch, parts, params, gibberish]);
}
makelink(ctrl, method, params={}){
let myRoles = (typeof(this.roles)=='function') ? this.roles() : this.roles ;
for(let route of this.routes){
let intersect = route['role'].filter(v => (myRoles.indexOf(v)>-1));
if((route.role!='*') && (intersect.length==0)) continue
if(('/'+route.controller).replace(/\/+/g,'/')!=('/'+ctrl).replace(/\/+/g,'/')) continue
let url=route.url;
//Check if params and route params match
let parok=true;
let rparams = route.url.match(/:\w+/g);
if(rparams){
let re;
for(let rparam of rparams){
rparam=rparam.substr(1);
if(!params.hasOwnProperty(rparam)) {
parok=false;
break;
}
re = RegExp(':'+rparam+'(?!\w)','g');
url = url.replace(re, '/'+params[rparam]);
}
}
if(!parok) continue;
if(!route.hasOwnProperty('method')){
url+=('/'+method).replace(/\/+/g,'/');
}
return(url);
}
return('');
}
}
app.registerClass('Router', Router);