Technology Blogs by Members
Explore a vibrant mix of technical expertise, industry insights, and tech buzz in member blogs covering SAP products, technology, and events. Get in the mix!
cancel
Showing results for 
Search instead for 
Did you mean: 
vvdries
Contributor
Hi CAP-Developers and enthusiasts!

In 2020 I’ve been playing around with SAP CAP and Cloud Foundry quite a lot. In the very beginning it was a lot to process, but I really enjoyed the journey and I love the results/demos it brought along.

I believe the coolest thing about CAP and the Cloud Foundry Environment, is the freedom it offers the developer. Every extra feature or integration is just one “plugin” away. If you can think it, you can build it. Obviously always make sure it makes sense.

On top of this CAP and Cloud Foundry freedom, we can even choose our preferred IDE. Usually this will be VS Code or the SAP Business Application Studio. Both IDE’s have their own advantages and disadvantages of course.

Now in this blog I want to make use of this freedom by using WebSocket’s in an SAP CAP application. As some of you may already know, I’m a big fan of real-time applications. Which is why I wanted to investigate this possibility and share it in a very basic example to get started. In the end you will notice there are more ways and maybe better and more performant solutions to implement WebSocket’s. But like I said, this is more about how to get started with the basics.

I chose the SAP Business Application Studio as development IDE for this example app. This because it offers me all the tools and features I need in a preconfigured workspace. Which makes it easier to follow the example as well.

What are we building?


We will be building an application where an employee of a company has the possibility to add an idea for a team event (mocked via service / no UI5 coding). The user will be notified (via a "MessageToast" in UI5) of all the newly created ideas, added by his or her colleagues in real-time. This without the need to refresh the application.

Long story short => a real-time application using WebSocket’s.

Building the app


The development of such an application might sound a little hard. But in the end you will see it only requires a few commands, CDS and JavaScript files.

The power of CAP to the fullest!

I will not go over every single step to setup a CAP application or UI-module since this blog is about "how to integrate WebSocket’s in a CAP app". The setup of a CAP app will not be a secret to most of you anymore. 😊  But I will list alle the commands to prepare the project in the SAP Business Application Studio so you can follow along.

Let’s get started!

Execute the following commands to initialize the CAP-project and add a Fiori Module and Approuter:
cds init RealTimeCAP
cd RealTimeCAP
yo fiori-module

Pass the following arguments to the "Yeoman fiori-module" generator:

Execute the following command to create a database schema:
touch db/schema.cds

Add following code to it, to create an "Idea" entity:
using {managed} from '@sap/cds/common';

namespace RealTimeCAP.db;

entity Ideas : managed {
key ID : UUID;
name : String;
description : String;
}

Execute the following command to create an OData Service for your "Idea" entity:
touch srv/catalog-idea-service.cds

Add following code to it, to expose the "Idea" entity:
using {RealTimeCAP.db as db} from '../db/schema';

service CatalogIdeaService {
entity Ideas as projection on db.Ideas;
}

Execute the following command to add custom logic to your OData Service:
touch srv/catalog-idea-service.js

Add following code to it:
const WebSocketServer = require('ws').Server;
const ws = new WebSocketServer({
port: process.env.PORT || 8080
});

module.exports = async (srv) => {
srv.after('CREATE', '*', async (ideas, req) => {
for (const client of ws.clients) {
client.send(JSON.stringify(ideas));
}
});
}

This custom logic in the “catalog-idea-service.js” file starts a WebSocketServer and will send the created entity response to every connected client. This because of the “after event handler” attached to the service. It will only be triggered for a “CREATE” operation, but for every single entity because of the wildcard “*”.

Execute the following command to install the WebSocket NPM module:
npm i ws

Execute the following command to install all the other dependencies inside the "package.json" file:
npm install

Execute the following command to deploy your database module to a local SQLite database:
cds deploy --to sqlite:myDatabase.db

Replace the configuration in the “xs-app.json” file inside your “realtimecap-approuter” with the following configuration:
{
"welcomeFile": "HTML5Module/index.html",
"authenticationMethod": "none",
"websockets": {
"enabled": true
},
"logout": {
"logoutEndpoint": "/do/logout"
},
"routes": [
{
"source": "^/NodeWS(.*)$",
"target": "$1",
"authenticationType": "none",
"destination": "NodeWs_api",
"csrfProtection": false
},
{
"source": "^/odataSrv/(.*)$",
"target": "/catalog/$1",
"authenticationType": "none",
"destination": "srv_api",
"csrfProtection": false
},
{
"source": "^/HTML5Module/(.*)$",
"target": "$1",
"authenticationType": "none",
"localDir": "../HTML5Module/webapp"
}
]
}

Notice that we enabled the “WebSockets” and a route for the “HTML5Module”, “OdataSrv” and “NodeWS” was added.

Execute the following command to create a "default-env.json" file to "mock" your destinations:
touch app/realtimecap-approuter/default-env.json

Add the following configuration to it:
{
"destinations": [
{
"name": "srv_api",
"url": "http://localhost:4004",
"forwardAuthToken": true
},
{
"name": "NodeWs_api",
"url": "ws://localhost:8080",
"forwardAuthToken": true
}
]
}

Like configured in the “routes” inside the “xs-app.json” file we define the URLs to these services with the appropriate ports.

Execute the following command to install the dependencies inside your "Approuter" directory:
npm install --prefix app/realtimecap-approuter/

Replace the code of your “IdeaOverview.controller.js” file inside your “HTML5Module” with the following code:
sap.ui.define([
"sap/ui/core/mvc/Controller",
"sap/ui/core/ws/WebSocket",
"sap/m/MessageToast"
],
/**
* @param {typeof sap.ui.core.mvc.Controller} Controller
*/
function (Controller, WebSocket, MessageToast) {
"use strict";

return Controller.extend("ns.HTML5Module.controller.IdeaOverview", {
onInit: function () {
var connection = new WebSocket("/NodeWS");
// connection opened
connection.attachOpen(function (oControlEvent) {
console.log(oControlEvent.getParameter("data"));
});

// server messages
connection.attachMessage(function (oControlEvent) {
var oIdea = JSON.parse(oControlEvent.getParameter("data"));
MessageToast.show(JSON.stringify(oIdea));
});

// error handling
connection.attachError(function (oControlEvent) {
console.log(oControlEvent.getParameter("data"));
});

// onConnectionClose
connection.attachClose(function (oControlEvent) {
console.log(oControlEvent.getParameter("data"));
});
}
});
});

This code creates a new WebSocket and opens the connection to the WebSocketServer. This via the "/NodeWS" path, earlier configured in the "Approuter" its "xs-app.json" file. It includes an "onmessage", "onError" and "onClose" listener as well.

Start your “Approuter” (npm run start in Approuter directory) and “Service” (cds watch in root directory). Next you create and execute the following “createIdea.http” file from your projects root-directory to create an Idea:
touch createIdea.http

Add the following request to it:
POST http://localhost:4004/catalog-idea/Ideas HTTP/1.1
Content-Type: application/json

{
"name": "BBQ",
"description": "With a lot of good food!"
}

You will see the "Idea" has been created successfully:

As you can see the “MessageToast” shows the created “Idea” data.

Duplicate the current tab so you have the app opened twice. You can create/add another idea and you will see that both apps receive the newly created "Idea".

Once received you could put them in a JSON-Model and display them in the app. On initial load you could also consume your OData-service to display all the current/earlier created ideas. Or you could add an "Idea" via the app and all the clients will be notified via the WebSockets.

Wrap up


In this blog we generated a CAP-project from scratch by executing some commands. We added a WebSocketServer and WebSocketClients to our project. Once an entity inside the CAP OData Service has been created, the result will be passed to every single connected WebSocketClient (the UI5 app). This UI5 app connects to the socket and OData Service via the Approuter, which has the "WebSocket" feature enabled in its configuration file.

I found this an easy way to setup and get started with WebSockets in CAP, and I would be happy to learn more on how you guys would implement it !

Hope this helps to get started easily and quickly with WebSockets in CAP!

Best regards,

Dries Van Vaerenbergh

 
17 Comments
yogananda
Product and Topic Expert
Product and Topic Expert
Great Article vvdries ! Keep sharing ...
vvdries
Contributor
0 Kudos
Hi Yogananda,

Thanks for the feedback! Definitely will do! 🙂

Best regards,

Dries
Ben
Participant
Hi vvdries

Great blog! I was also using Websockets in a CAP project and wanted to ask if you also observed following glitch.

When testing (cds watch / run) in BAS everything worked fine but after deploying to SCP-CF the upgrade call from clients always got called twice which lead to error
handleUpgrade() was called more than once with the same socket, possibly due to a misconfiguration

I fixed it by a workaround storing the client's sec-websocket-key to avoid upgrading twice.. would be interesting if you also observed this?

Best regards,

Ben
vvdries
Contributor
0 Kudos

Hi benkrencker,

Thanks a lot for your feedback and interesting question!
Also, my apologies for the late reply. 😊
I wanted to check out if the "handleUpgrade()" gets called twice, but to be honest I ran into a problem deploying this blog's cap project to Cloud Foundry.
When I check the logs, I see the srv-app contains an error "address 0.0.0.0:8080 already in use".
Probably it has something to do with the place I created the WebSocket server? I thought the srv-app would run on port 4004. But maybe that is only in development and it is using port 8080 on Cloud Foundry. Could that be you think?
I'm curious and really interested on how you deployed the CAP project with the WebSocket’s. Or did you created an extra Node module to take care of the WebSocket?

That being said and asked, I also had a look at your question. Which I started with initially, but since the deployment issue ... 😊
So to do a little test at least, I created a basic multi target application project, added a standalone approuter, ui5 app, and an extra Node module for the WebSocket containing the following code:

const WebSocketServer = require('ws').Server;
//We will create the websocket server on the port given by Cloud Foundry --> Port 8080
const ws = new WebSocketServer({
port: process.env.PORT || 8080
});

ws.on('connection', function (socket) {
socket.send(JSON.stringify({
"message": "Hi, this is the Echo-Server"
}));
});

The most basic implementation for a quick test. Also, I did not experience any deployment issues.
First of all, I saw the message "Hi, this is the Echo-Server" in the app and thus I checked out the network tab its "WS" tab and the call was only performed once. When I extended the app (to send messages to all clients) there was no double request to be found. (see image, all messages send via the socket in the same connection)

Maybe it could be a specific problem indeed with CAP and WebSocket’s like you mentioned earlier.
If you could give me a heads up on how you deployed the CAP WebSocket’s app to Cloud Foundry, I would be more than happy to have a look at it again. To see if in that case the request would be send twice as well.
Thanks a lot for your question and insights Benjamin! Hope to hear from you soon! 😊
Best regards,
Dries

Ben
Participant

Hi vvdries

Yes indeed in my project I followed a slightly different approach to create and upgrade Websocket server connection.

I placed the creation of the WS-Server into server.js file of the node srv as part of the default CDS server. I did not create a separate module too in order to notify the clients after an INSERT..

The magic is - I guess - that you need to tell the WebSocket Server to not start a new server. Since it is created as part of the CDS-server, it will use the CDS connection.

const wss = new WebSocket.Server({ noServer: true });

Furthermore I handle the "upgrade" event as part of the default cds server:

cds.on('listening', (cdsserver) => {

cdsserver.server.on('upgrade', function upgrade(request, socket, head) {

wss.handleUpgrade(request, socket, head, function done(ws) {
console.log("WSS HandleUpgrade ");
wss.emit('connection', ws, request);
});

}
});

global.wss = wss;

});

Then of course you'll also need a route in xs-app.json and a Destination to consume the WebSocket from UI5 (Client).. here you can use same approach like you did in your project.

Best regards,

Ben

vvdries
Contributor
0 Kudos
Hi Benjamin,

Thanks a lot for sharing your implementation! I really appreciate it!

I'm sure this will fix my deployment issue as well.

Best regards,

Dries
chris_hanusch
Explorer
0 Kudos
Hello Benjamin

 

Thanks for your post. I find myself struggling implementing the websocket on my CAP project, though. Where is the socket:
const wss = new WebSocket.Server({ noServer: true });

... being created?

 

My coding looks something like this:


class CatalogService extends cds.ApplicationService {
async init(srv) {
const wss = new WebSocketServer({
noServer: true
});


while the console gives me this:


WebSocket connection to '...' failed:  




Kind regards

Chris
Ben
Participant
Hi Chris

You put this in the server.js of the srv folder:
const cds = require('@sap/cds');
const WebSocket = require('ws');

const wss = new WebSocket.Server({ noServer: true });

wss.on('connection', function connection(ws) {
console.log("WebSocket Connection");
});

global.wss = wss;

..

Here you'll find an example project where websockets are used:

fs-demo-2022/server.js at main · RizInno/fs-demo-2022 (github.com)

Best regards,

Ben
chris_hanusch
Explorer
0 Kudos
Hello Benjamin

This looks good, thanks for the hint! I will try this tonight.

Kind regards

Chris
JérémyChabert
Participant
0 Kudos

***EDIT***

This was in the blog since the beginning but I didn't see it at first. Maybe it's what you're missing just like me

***

I found this blog this morning and succeed to made it work with a CAP backend and a Frontend UI5 app deployed in a standalone launchpad.

One small trick among all the others that are legit is to add 'websockets.enabled = true' as follow in xs-app.json.

{
"welcomeFile": "/index.html",
"websockets": {
"enabled": true
},
"authenticationMethod": "route",
...
}
sreehari_vpillai
Active Contributor
0 Kudos
thanks for this. But what about authentication ? I would need to validate the user for a valid token first before providing him a connection.
gregorw
Active Contributor
sreehari_vpillai
Active Contributor
0 Kudos
super. thanks a lot.
sreehari_vpillai
Active Contributor
0 Kudos
gregorw Is there an option to send messages only to specific clients ? I was expecting to attach request.user after authentication to a client, so that while sending message I will know whom to send the message ?

 
sreehari_vpillai
Active Contributor
0 Kudos
With this setup i send a message to all the clients connected. I implemented authentication while connecting following git repo

Is there an option to attach authenticated user context against a client upon connection ? so that I can loop the wss.clients and locate the target clients I can send a message against .
sreehari_vpillai
Active Contributor
0 Kudos
I replaced it with createSecurityContext as you mentioned in the codebase. Its food now. Coded as below.
const WebSocketServer = require('ws').Server;
const wss = new WebSocketServer({
noServer: true
});

wss.on("connection", (connection, request) => {
console.log("connection established : ", connection.email);
connection.on("close", (reasonCode, description) => {
console.log(' Peer ' +connection.email+' disconnected.');
});
});

cds.on('listening', (cdsserver) => {

cdsserver.server.on('upgrade',
function upgrade(request, socket, head) {
if (request.url !== '/websocket/notification') {
socket.reject();
}
let token = getTokenFromRequest(request);
xssec.createSecurityContext(token, xsenv.getServices({
uaa: 'uaa'
}).uaa, function (error, securityContext, tokenInfo) {
if (error) {
socket.destroy();
throw error;
} else {
let email = securityContext.getEmail();
wss.handleUpgrade(request, socket, head, (ws) => {
ws.email = email;
wss.emit("connection", ws, request);
});
}
})

});
global.wss = wss;

});

cds.on("bootstrap", async (app) => {

});

cds.on("shutdown", () => {
console.log("Graceful shutdown");
});
sreehari_vpillai
Active Contributor

Did this and works as expected. 

const WebSocketServer = require('ws').Server;
const wss = new WebSocketServer({
noServer: true
});

wss.on("connection", (connection, request) => {
console.log("connection established : ", connection.email);
connection.on("close", (reasonCode, description) => {
console.log(' Peer ' +connection.email+' disconnected.');
});
});

cds.on('listening', (cdsserver) => {

cdsserver.server.on('upgrade',
function upgrade(request, socket, head) {
if (request.url !== '/websocket/notification') {
socket.reject();
}
let token = getTokenFromRequest(request);
xssec.createSecurityContext(token, xsenv.getServices({
uaa: 'uaa'
}).uaa, function (error, securityContext, tokenInfo) {
if (error) {
socket.destroy();
throw error;
} else {
let email = securityContext.getEmail();
wss.handleUpgrade(request, socket, head, (ws) => {
ws.email = email;
wss.emit("connection", ws, request);
});
}
})

});
global.wss = wss;

});

cds.on("bootstrap", async (app) => {

});

cds.on("shutdown", () => {
console.log("Graceful shutdown");
});

 

 

Labels in this area