Navigation Guards with VueRouter in UIBUILDER?

I'm wanting to wrap Navigation Guards around my SPA Vue3 app, hosted inside a UIBUILDER instance. My goals are:

  • Nav Guards which prevent unauthorised access to fragments via redirect to either a login page if not authenticated, or a "home" page if not authorised.
  • DB lookups from my NodeRED flow, to check for a valid user sessionId, and the level of authorisation (none/readOnly/readWrite) that the sessionId has to the requested resource and sub-features within the requested resource.

I intend to have granular authorisation controls on the DB side where a user inherits from a group set of permissions, but can then have per-user variations on a given permission. I've made good progress on the Flow/DB side with DB storage of hashed pwd only, datetime-based expiry of sessionId, and force pwd change on first login.

I'm open minded whether to use UibRouter vs VueRouter (vue-router), but I think my needs are pushing me toward the latter. In particular, the .beforeEach(to,from) is very attractive. I prefer the idea of denying access and re-routing (or not loading fragments), rather than loading all fragments and relying on client-side hiding of unauthorised fragments. For due diligence I've taken the UibRouter example for a spin then I've had a read through uibrouter.js (v7.4.1) source. My reading of the source suggests that the current UibRouter logic lacks the "check before routing" capability that VueRouter offers. No shade on UibRouter - just an observation of what it does/doesn't do. Hopefully this give understanding why I'm leaning toward VueRouter for my scenario/complexity.

So, with UIBUILDER wrapping a Vue3 app which is using VueRouter for Nav Guards, my question then becomes:
What is the sensible approach in implementing an async call from within a UIBUILDER-hosted VueRouter .beforeEach() to trigger a Flow-based DB lookup, such that the results from DB lookup and other Flow-based processing returns to ultimate outcome to the UIBUILDER-hosted VueRouter .beforeEach() function, for VueRouter .beforeEach() to make the final re-route?

I guess the question is rather broad, multi-facetted and probably touches on:

  • Vue+UIBUILDER architecture and best practice.
  • How to make async calls properly - something I'm no expert at (yet!).
  • Whether the standard UIBUILDER mechanism of uibuilder.send() and uibuilder.onChange('msg', (msg)=>{}) is the correct/only way of exchanging data between the VueRouter .beforeEach() function and the Flow.
  • Assuming the above point is the case, what the appropriate approach would be in uibuilder.onChange('msg', (msg)=>{}) to tie the DB go/no-go results back into the VueRouter .beforeEach() function, for VueRouter to perform the final re-route.

Aside: I consider myself to be "beginner" with Vue experience, with my experience entirely developed within UIBUILDER Vue2 templates. I'm gradually stepping into Vue3. My development style is one of "need drives me to try things", historically though trial-and-error but more recently through AI queries. My formal studies majored in AI many decades ago but my subsequent career path took a different direction. I know well enough to treat AI guidance with a grain of salt, but with separate exploration of its suggestions it also tells me things I would otherwise remain ignorant about.

With all the above in mind, my efforts have lead me to the current position. I'm currently defining router.beforeEach() with an async callback. The callback is accessing an object in my Vue app using the provide()/inject() approach. Only showing pertinent code bits for now, to keep it a bit focused.

router.beforeEach(async (to, from) => {
	const getSessionObject = app.runWithContext(()=>inject('getSessionObject',({ /* Other secret sauce items of interest to me */ })));
	...
	try{
		const sessionObject = await getSessionObject(sessionId);  //Provided by the Vue app below.
		
		//Check my results in sessionObject.  Re-route as appropriate.
	}
});

const app = Vue.createApp({
	data() { return {
		sessionId: '',
		/* Other secret sauce */
	}},
	/* Other app stuff */
});

app.provide('getSessionObject', async (sessionId) => {
    uibuilder.send({topic:'checkSession',payload:{sessionId:sessionId}});
	
	/* ???????   How do I "pause" here and wait for a message back from the DB-lookup/Flow, the content of which will be used in the below return {} statement. ????? */
	
	return { /* Things for the .beforeEach() NavGuard to use to decide re-routing outcome.*/};
});

app.use(router);
app.mount('#app');

Phew, that was a long question! Congrats to those still reading.

Phew, a lot in that "question" :smiley:

Lets start with front-end routers. The uibuilder router is only meant as a simple tool. It covers quick and simple needs such as most home dashboards for example. So if your needs are more comprehensive then by all means use the more expansive tools provided by a framework.

I'm not much of a Vue user any more since I don't have a need for that level of complexity. But in terms of your async question, the interactions between the browser and node-red are really always async. Something is sent from the browser to node-red over the Socket.IO comms but that comms has no realtime feedback. It is up to your flows to decide how/whether to send a response.

So in order to deal with that, you create separate listener functions using one of uibuilder's event handlers. uibuilder.onChange('msg', (msg) => {....}) is the basic one and what a lot of people will use. It is triggered when any standard message is received from node-red via the uibuilder node. In fact, that event handler can be used to listen for changes to any uibuilder managed variable. msg is most common but ctrlMsg for uibuilder's control messages from node-red or indeed any managed variable including ones you set yourself using uibuilder.set('varname', value).

For convenience, there is also a uibuilder.onTopic('topic-name', (msg) => {....}) which responds to standard messages with a specific topic name.

If you have multiple topics to manage though, best to use onChange and if or switch statements to trigger appropriate functions.

So the process is:

Browser                      Node-RED
-------                      --------
Vue method -+
            |
uibuilder.send({}) -> uibuilder node -+
                     /  ^             |
                    /   |             |
onMsg()     <------+    |             v
    |                  flow response -+
 process
response                                     

So that is the basic way that comms between the browser and node-red work as you've already noted. There is no direct connection right now between the send and a receive.

If I'm reading this right though, your .beforeEach() Vue method has no way to wait for the response from Node-RED. If we absolutely needed that, I would have to make some changes to uibuilder to implement a promise-based send-with-response function. And actually, that's not a bad idea and goes straight into my backlog! But it doesn't help your immediate issue.

I've never used the Vue router so I'm not familiar with it. Is it possible to prevent the route change in .beforeEach() and then manually trigger it in a uibuilder.onChange()? I think that would be the simplest approach if possible.

And a quick update - thanks to a slow day!

I've started work on an asyncSend function for the uibuilder client library. It will go into the next release (v7.6 most likely). Not tested properly yet but it does what I think you need. Returns a promise that is only fulfilled successfully if the message you send gets a return message with the same topic and a matching internal UUID. It has a timeout you can set as well.

Let me know if you want me to try and extract it as a standalone function since v7.6 is some way off yet thanks to the big new feature, the new uib-markweb node that lets you easily create a full website out of a collection of Markdown files.

Hi Julian,

Thanks as always for your rapid response.

I probably should have included the following link for anyone wanting to do some background reading. Navigation Guards | Vue Router Using the terminology from that page, I'm trying to implement Global Before Guards. I've had good re-routing success when I fake the async call in .beforeEach(). My sticking point is that the whole mechanism really does need to be async. Their examples are somewhat "yeah, go figure it out the async bit for yourself" and since I'm hosting my code in a UIBUIDER environment I figured this is the right place for my question, rather than general Vue3 forums.

Your suggestion of an asyncSend sounds absolutely perfect for my scenario. The timeout is also an excellent thought. As an end-user of your asyncSend function I could imagine a few different ways to detect/manage the timeout:

  • Have asyncSend return the original message, with timeout:true added to the message. Preservation of the original message will probably be useful to some people.
  • Wrap it in a try/catch block and put the burden back on us.

In my VueRouter .beforeEach() scenario, I'd then use the timeout to re-route to an "Oops, something went wrong" page.

I'm happy to test the asyncSend before v7.6 if it is no hassle, but also happy to wait if v7.6 is only a few months away. I could probably fake this aspect of my dev work for another months or two while I build out the rest of my app. It's an "internal business improvement" app with no hard delivery date.

In the mean time I'll get on with updating my NodeRED environments to the latest, so I can install UIBUILDER v7.6 when it comes out. I'm one of those more conservative folk who is running NodeRED 4 on Node 18, since at the time of NR3->4 migration there were some ongoing concerns around Node 20 serial port performance. Is seems that as of UIBUILDER v7.5 you've "baked in" Node 20 as a dependency, which is what has been holding me back from the latest UIBULIDER version. The tide of continual improvement washes over me once more... :slight_smile:

Presuming that you are sticking with Node 20 as the minimum dependency, you may want to do some minor housekeeping for the "Compatibility of current release" section on this page: https://www.npmjs.com/package/node-red-contrib-uibuilder?activeTab=readme There is no mention of NodeRED or Node.js versions on the following page UIBUILDER Documentation v7 but perhaps that is "by design".

That should not be the case. I track the Node-RED dependencies so as long as you are using node.js v18.20 or above, you should be good for any uibuilder version in the v7 series. Just note that when node-red moves to v5, I'll at some point move uibuilder to v8 and match the node.js version - probably at v24 I think.

That page is correct as it stands.

Well, that's a good point, I should probably add some version info to that page too.

OK, so here is the function that is being added to v7.6 - amended so that it should work if you add it to your own code - not especially tested in this form but I think it should be OK.

/** Send a message to Node-RED and return a promise that resolves when a matching response is received.
 * The response must have the same msg.topic and msg._uib.correlationId to match.
 * @param {object} msg The message to send to Node-RED
 * @param {object} [options] Options for the async send
 * @param {number} [options.timeout] Timeout in milliseconds (default: 60000 = 60 seconds)
 * @param {Function} [options.onSuccess] Optional callback function to call on success. Receives the response msg.
 * @param {string} [options.originator] Optional Node-RED node ID to return the message to
 * @returns {Promise<object>} Promise that resolves with the response message or rejects on timeout
 * @example
 * // Basic usage
 * const response = await uibuilder.asyncSend({ topic: 'myTopic', payload: 'Hello' })
 *
 * // With options
 * const response = await uibuilder.asyncSend(
 *     { topic: 'getData', payload: { id: 123 } },
 *     { timeout: 30000, onSuccess: (msg) => console.log('Got response:', msg) }
 * )
 *
 * // Using .then()
 * uibuilder.asyncSend({ topic: 'query', payload: 'test' })
 *     .then(response => console.log('Response:', response))
 *     .catch(err => console.error('Failed:', err))
 */
asyncSend(msg, options = {}) {
    const timeout = options.timeout ?? 60000
    const onSuccess = options.onSuccess
    const originator = options.originator ?? ''

    // Ensure msg is an object
    msg = uibuilder.makeMeAnObject(msg, 'payload')

    // Generate a unique correlation ID
    const correlationId = (typeof crypto !== 'undefined' && crypto.randomUUID)
        ? crypto.randomUUID()
        : `${Date.now()}-${Math.random().toString(36)
            .substring(2, 11)}`

    // Ensure msg has a topic
    if (!msg.topic) {
        msg.topic = 'uibuilder/asyncSend'
    }

    // Add correlation ID to the message
    if (!msg._uib) msg._uib = {}
    msg._uib.correlationId = correlationId

    const topic = msg.topic

    return new Promise((resolve, reject) => {
        // Cleanup function to remove listener and clear timeout
        const cleanup = (tid, lref) => {
            if (tid) clearTimeout(tid)
            if (lref !== undefined) {
                uibuilder.cancelTopic(topic, lref)
            }
        }

        // Set up timeout
        const timeoutId = setTimeout(() => {
            cleanup(timeoutId, listenerRef)
            reject(new Error(`asyncSend timeout after ${timeout}ms for topic "${topic}" with correlationId "${correlationId}"`))
        }, timeout)

        // Set up listener for response
        const listenerRef = uibuilder.onTopic(topic, (responseMsg) => {
            // Check if the correlationId matches
            if (responseMsg._uib?.correlationId === correlationId) {
                cleanup(timeoutId, listenerRef)

                // Call onSuccess callback if provided
                if (typeof onSuccess === 'function') {
                    try {
                        onSuccess(responseMsg)
                    } catch (err) {
                        console.warn('[Uib:asyncSend] onSuccess callback threw an error', err)
                    }
                }

                resolve(responseMsg)
            }
            // If correlationId doesn't match, ignore this message (keep waiting)
        })

        // Send the message
        uibuilder.send(msg)
    })
}

Let me know if you spot any issues of course.

I've not really thought about that. Might be a good idea. Give the current version a try and let me know if it needs tweaking or improving.

In general, promise based async methods should generally fail with an error if there is an issue. This is picked up in a catch function if using method().then().catch() style or with a normal try {} catch (err) {} block if using async/await. That is how this function behaves.

Basic examples are included in the JSDoc for the shared function. More complete documentation will be in the next release of course.

Hopefully this is enough to get you going for now. :smiley:

Thanks again for such a rapid turn around. I'll give it a go in the coming hours and will report back. I'll then take a few days off to cope with the impending heatwave in my hemisphere.

Node 18 vs Node20. I happily stand corrected. Of the 12+ instances that I look after I know at least one (probably my dev unit) failed to upgrade to UIB7.5, giving a NJS 20 dependency message in the error log. I've since done a re-install of the latest NodeRED and NJS20 on that unit, and everything upgraded just fine and dandy. I've then tried updating UIB to v7.5 on a second NJS18 test system and that worked okay. :thinking: Let's call it a "non issue" and my mistake, though I'll keep my eyes open for any recurrence and I'll grab a screenshot if I encounter it again.

I think this is guiding me in the right direction, despite me not being able to whip up a fully working end-to-end solution in a few hours.

There is considerable complexity in my DB-wrangling code, so I've set it aside for now and have created a "minimum viable" uibuilder scenario to test my approach using the asyncSend() function, and verify that Vue Router is behaving as expected. On the Flow side of this bare bones test there is no DB, just some simple message logic wrapped in a setTimeout() so I can simulate delayed responses. I'll keep kicking at it next week when hopefully the heatwave will have passed.

1 Like