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
+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);