Skip to content

wridgeu/ui5-websocket-demo

Repository files navigation

WebSocket Demo Implementation

A demo repository showcasing WebSocket and SAP PCP (Push Channel Protocol) integration with SAPUI5, featuring a reusable retry strategy with exponential backoff, an eventing facade, and a custom terminal-like event log control.

Demo overview Demo overview showing the event log terminal with connection, messaging, and retry events

Connection initialization Connection initialization showing the backend handshake on first connect

Reconnection with greeting Reconnection scenario showing the backend greeting after a successful retry

Usage

  1. Have NodeJS installed (at least v24)
  2. Install all dependencies aka.
    npm i
  3. Run Front- and Backend in parallel via
    npm start

Alternative

Run the simple WebSocket Server via

npm run start:backend

Run the frontend via

npm run start:frontend

Why?

I recently worked a bit with WebSockets and had the fun to also use APC/AMC in addition to that.

In my specific use-case we have some sort of pseudo-login which is responsible for establishing a WebSocket connection. As only after the user is "logged in" we retrieve the URL to which our WebSocket needs to connect to. We also close a connection whenever the user performs a "logout"/leaves a certain page. I know logouts are tricky and I don't want to go too deep into that. The "logout" I'm speaking of here is a simple backwards navigation.

Note In this scenario the application itself is embedded into the SAP Fiori Launchpad.

Of course one would need to also consider:

  • ICF Path for logoff (Launchpad Logoff)
  • F5/Tab Refresh
  • Closing the Browser
  • Closing the Tab
  • etc.

Most of these scenarios are tricky to handle cleanly. If you have some Best Practices though, go ahead and shoot me a message, I'm happy & eager to learn. The Beacon API can help with some of these cases.

So what did I do? I created a "WebSocketService" that does exactly what I need it to do. Not as flexible, resilient and overall good designed as I'd like it to be but well, there are deadlines to be kept. The Service I'm using in this repository is a bit different as it uses some sort of "EventFacade", or "Registry" if you will(?), to hide some of the attachEvent logic behind an object and forward these EventRegistrations to the actual Service to not bloat up the WebSocketService object itself too much.

Overall It was my first time not directly going for the EventBus so I was excited to learn new things and tried out extending the EventProvider. This gives us the possibility to work on a less "global" level and have a better overview of whats happening, compared to using a general & generic global eventing tunnel (like the EventBus). So ... in theory better maintainability, right?

Note

Quoting the UI5 Team here: "It is recommended to use the EventBus only when there is no other option to communicate between different instances, e.g. native UI5 events. Custom events can be fired by classes that extend sap.ui.base.EventProvider, such as sap.ui.core.Control, sap.ui.core.mvc.View or sap.ui.core.Component, and the events can be consumed by other classes to achieve communication between different instances. Heavily using the EventBus can easily result in code which is hard to read and maintain because it's difficult to keep an overview of all event publishers and subscribers."

The way I'm using it though, I'm not so sure if this "better maintainability" is still a factor or if what I did even makes sense. When I first came up with this I was quite happy though, I'll admit that much. I'm generally usually unhappy with what I do even if it does what it needs to. I just think I'm quite horrible in building/designing these things. Honestly speaking I'd probably rethink the entire thing about 300 more times, if I had the time. Then again, there is only so much "Web Developer" inside of me, and no one I can really ping-pong off for ideas so I'll leave it as is for now.

Design/Idea Overview

I'm horrible with UML, so don't judge me. 😅 Some of the classes mentioned here aren't "real" compositions as they're not directly passed in through the constructor, but you get the gist.

class_diagramm

The above Image describes a way of breaking up the dependencies between the WebSocketService and which Events other can listen to (attach/detach).

This is done by introducing a new Object, some sort of "registry" or "facade" where every possible event which could be fired according to an Actions-ENUM will be wrapped to expose a more "user-friendly"-API. Instead of retrieving the WebSocketService Instance and manually attaching events (+ using the ENUM) like so:

const myWsService = this.getWebSocketService();
myWsService.attachEvent(ENUM.action, this.myHandlerFunction);
myWsService.detachEvent(ENUM.action, this.myHandlerFunction);

you could do it like this:

const myFacade = this.getWebSocketService().getEventingFacade();
myFacade.attachMyActionEvent(this.myHandlerFunction)
myFacade.detachMyActionEvent(this.myHandlerFunction)

A similar approach can be seen here where the UI5 "MessageProcessor" takes care of wrapping the "messageChange"-Event accordingly so it is easier to consume/use from the outside.

There are a few events which haven't had the honor to be taken into the ENUM. Namely all the events that do not map to a particular action that the frontend necessarily wants to listen to. The ENUM is just used as some sort of "contract", to get some sort of consistency with whatever comes from the backend. Therefore some of the rather technical/fallback events like close, open and the general message are not considered here. That is not a problem though, as these can just be taken into account within the "EventFacade".

RetryStrategy (Exponential Backoff)

The RetryStrategy (classes/retry/RetryStrategy.js) is a standalone, reusable utility that handles reconnection attempts with exponential backoff and jitter. It is decoupled from the WebSocketService and can be used for any operation that needs retry logic.

How it works:

  1. On each call to schedule(fn), the internal delay doubles (starting from initialDelay) until it reaches maxDelay.
  2. A random jitter (between 0 and maxJitter ms) is added to each delay to prevent multiple clients from retrying at the exact same time (the "thundering herd" problem).
  3. After maxAttempts retries, schedule() returns false and no further retries are made.
  4. Calling reset() restores the strategy to its initial state. This should be done after a successful connection.

All settings are configurable via the constructor:

const retry = new RetryStrategy({
    initialDelay: 1000,  // start with 1 second
    maxDelay: 16000,     // cap at 16 seconds
    maxAttempts: 10,     // give up after 10 tries
    maxJitter: 3000      // add 0-3 seconds of random jitter
});

The RetryStrategy extends EventProvider and fires lifecycle events that consumers can listen to:

  • scheduled - fired when a retry is scheduled, with parameters { attempt, delay }
  • maxAttemptsReached - fired when the max number of attempts has been reached, with { attempts }
  • reset - fired when the strategy is reset to its initial state

The WebSocketService uses it internally: on abnormal close it calls retry.schedule(() => reconnect()), on successful open it calls retry.reset(), and on intentional close it calls retry.cancel().

You can test the retry behavior in the demo application using the two "Test Retry" buttons, which simulate different failure scenarios (server-initiated close vs forceful connection drop).

EventLogTerminal (Custom Control)

An in-app terminal-like event log control with color-coded entries, auto-scroll, and the ability to automatically wire up to any EventProvider via connectSource().

See the full documentation in frontend/webapp/control/README.md.

F.A.Q

Why is the connection setup (WebSocket Instantiation) not happening in the constructor?

That indeed would make everything much easier. In my use-case I wanted to have the WebSocketService somewhat global but not as generic as the EventBus. That is why the Service Instance lives at component level and is implicitly handled as Singleton. Implicitly because the instance doesn't really prevent instantiation on a technical level (this could be changed though). It is instantiated and destroyed on component level (Component.js: init & destroy) and handed down into the rest of the application via the BaseController.

BaseController.js

getWebSocketService(){
  return this.getOwnerComponent().getWebSocketService()
}

In our case we didn't need multiple connections to different URLs however, the actual URL for the connection was only available at a certain point in time (after the pseudo-login). Which is why the Constructor of the WebSocketService is not able to directly instantiate a WebSocket Instance. As the points in time for Service Instantiation and WebSocket instantiation differ.

An alternative way would be, to throw this "WebSocketService" away completely and directly use the UI5 standard WebSocket within one of the controllers but then again I think I would need to make use of the EventBus or similar to handle the reconnection and all that "properly".

I've seen other applications not taking care of any of these things and simply creating a connection without ever closing them or caring if a reconnection is required. So you could definitely get away with simply opening up the connection, forwarding the events somehow (EventBus) or directly handling them in the current Controller and then never care about anything else that comes after.

On the consumer side, if you need to present multiple incoming messages to the user as sequential dialogs without them overlapping, check out ui5-msg-box-sequencer.

Is the "Facade" necessary?

Not at all. Any Controller/Object can just take the "WebSocketService"-Instance and call attachEventXYZ or detachEventXYZ on it.

The facade is a convenience layer, nothing more. The WebSocketService itself is deliberately ignorant of which actions exist: it fires whatever action string arrives from the backend as a generic event, so consumers can always bypass the facade and attach directly:

const oWs = this.getWebSocketService();
oWs.attachEvent(WebSocketMessageAction.SOME_ACTION, this.onSomeAction, this);
oWs.detachEvent(WebSocketMessageAction.SOME_ACTION, this.onSomeAction, this);

There is a trade-off to be aware of: the facade wraps one method pair per action, so as the ENUM grows, the facade grows with it. In this demo there are only two actions (SOME_ACTION, PING_PONG), so the facade is cheap. In a real application with dozens of actions, maintaining wrapper methods becomes tedious and the direct attachEvent(ENUM.action, ...) form scales better without any code changes in the service. Pick whichever side of that trade-off fits your codebase, both paths are fully supported.

Are you using valid JSDoc?

Yes! For custom types in UI5 projects (with custom namespaces etc.), @typedef is the way to go. You either define the type in the file where it's used, or define it in one central file and import it in others via @import (or the older @typedef {import("./path").MyType} MyType pattern). This is the standard approach, there's no hidden "better way".

NodeJS WebSocket Server for Testing

I'm using a simple NodeJS WebSocket Package called "ws" to spin up a small WebSocketServer for some testing.

Credits

This project has been generated with 💙 and easy-ui5.

I'm standing on the shoulders of giants. Thanks.