cco-web-app-bridge is now Open Source: Build your first SAP CCO Web App Plugin
A few weeks ago I published an article about a different approach to SAP Customer Checkout plugin development: embedding a standard Single Page Application inside a CCO plugin, communicating with the POS through a postMessage bridge, and shipping everything as a single offline-capable JAR. If you haven't read it yet, I recommend checking it out first for the full backstory and motivation.
Today I'm happy to announce that the library behind this concept -- cco-web-app-bridge -- is now open source and available on Maven Central. Alongside it, I'm releasing a complete demo plugin called FoodInfo that you can use as a reference or starting point for your own projects.
- Library: github.com/stefanbaust/cco-web-app-bridge
- Demo: github.com/stefanbaust/cco-web-app-bridge-demo-foodinfo
The concept in a nutshell
The idea is straightforward: instead of building complex plugin UIs in vanilla JavaScript using the CCO component model, you build a standard SPA (Angular, React, Vue -- whatever you prefer) and embed it in an iframe inside the POS. A JavaScript bridge handles the communication between your app and the CCO APIs -- receipt data, event bus, store access -- all exposed as a clean async API. The SPA gets bundled into the plugin JAR, so the whole thing works offline without any external web server.

From the SPA side, talking to the POS looks like this:
const pos = new POSBridge();
await pos.ready();
const receipt = await pos.getReceipt();
pos.on('receiptChanged', (data) => { /* react to changes */ });
pos.pushEvent('SHOW_MESSAGE', 'Hello from the iframe!');
Building a plugin step by step: The FoodInfo example
The FoodInfo plugin looks up product nutritional information from the Open Food Facts API based on the barcode of the currently selected receipt item. It has two views: a compact embedded card in the POS UI, and a full-screen popup with detailed product information, NutriScore, allergens, and nutrition facts.
Let me walk you through how to build a plugin like this from scratch.
Prerequisites
- Java 17+
- Maven 3.9+
- Node 22 (the build can install it automatically via
frontend-maven-plugin) - The SAP CCO
ENV.jarlibrary installed in your local Maven repository. This library is not publicly available -- you need to obtain it from SAP and install it locally.
Step 1: Set up the Maven project
The cco-web-app-bridge library is published on Maven Central, so you can add it as a regular dependency. The ENV.jar is provided scope since CCO supplies it at runtime.
<dependencies>
<dependency>
<groupId>dev.baust.cco.webapp.bridge</groupId>
<artifactId>cco-web-app-bridge</artifactId>
<version>X.X.X</version>
</dependency>
<dependency>
<groupId>com.sap.customercheckout.pos</groupId>
<artifactId>ENV</artifactId>
<version>3.X.X</version>
<scope>provided</scope>
</dependency>
</dependencies>
To build the Angular frontend as part of the Maven build, add the frontend-maven-plugin. It installs Node automatically and runs npm install and npm run build during the generate-resources phase. The maven-shade-plugin then bundles everything -- Java classes, the bridge library, and the compiled SPA assets -- into a single JAR.
The FoodInfo demo's pom.xml shows the complete build configuration.
Step 2: Create the plugin class
This is the Java side. Your plugin class extends AbstractWebAppBridgePlugin and wires the CCO annotations to the bridge methods. The constructor takes a unique prefix string -- this prevents collisions when multiple bridge-based plugins run simultaneously.
public class FoodInfoPlugin extends AbstractWebAppBridgePlugin {
public FoodInfoPlugin() {
super("FOODINFO");
}
@Override
public String getId() { return "FoodInfoPlugin"; }
@Override
public String getName() { return "Food Info Web App Bridge Plugin"; }
@JSInject(targetScreen = "NGUI")
public InputStream[] jsInject() {
return getBridgeJsInject();
}
@CSSInject(targetScreen = "NGUI")
public InputStream[] cssInject() {
return new InputStream[]{};
}
@ListenToExit(exitName = "PluginServlet.callback.get")
public void pluginServletGet(Object caller, Object[] args) throws Exception {
handleBridgeServletGet(caller, args);
}
@ListenToExit(exitName = "PluginServlet.callback.post")
public void pluginServletPost(Object caller, Object[] args) throws Exception {
handleBridgeServletPost(caller, args);
}
@ListenToExit(exitName = PluginExitPoints.TECH_CONTROLLER_UI_EVENT_CHANNEL)
public void uiEventChannel(Object calledBy, Object[] args) {
handleBridgeUiEventChannel(calledBy, args);
}
}
That's the entire Java side. The base class handles all the complexity -- serving the SPA files, managing postMessage communication, proxying external API calls, and translating between the iframe and CCO's stores and event bus.
Step 3: Create the SPA
Create a webapp/ directory in your project root for your Angular (or React, Vue, etc.) application.
Include the POS Bridge SDK -- The bridge library ships a client-side SDK file called pos-bridge-sdk.js (you'll find it in the library's resources at src/main/resources/pos-bridge-sdk.js). Copy this file into your webapp project, for example into webapp/src/assets/pos-bridge-sdk.js. Then configure your build to place it at the root of the output directory. In Angular, you do this in angular.json:
"assets": [
{ "glob": "**/*", "input": "public" },
{ "glob": "pos-bridge-sdk.js", "input": "src/assets" }
]
And load it in your index.html:
<script src="pos-bridge-sdk.js"></script>
This gives your SPA access to the POSBridge class that handles all communication with the POS.
Routing with hash location -- CCO serves the iframe content through its PluginServlet, so you need hash-based routing:
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes, withHashLocation()),
provideHttpClient(withInterceptors([proxyInterceptor])),
],
};
The two routes correspond to the two display modes: #/embedded for the inline view and #/popup for the full-screen overlay.
The CORS proxy interceptor -- When running inside the POS, your SPA can't make direct API calls to external services due to CORS restrictions. The bridge provides a proxy endpoint for this. A simple HTTP interceptor rewrites external requests to go through the proxy when running in the POS environment:
export const proxyInterceptor: HttpInterceptorFn = (req, next) => {
const inPOS = window.location.pathname.includes('PluginServlet');
const isExternal = req.url.startsWith('http');
if (!inPOS || !isExternal) {
return next(req);
}
const params = new URLSearchParams(window.location.search);
const action = params.get('action') || '';
const prefix = action.replace(/Servlet$/, '');
const proxyUrl =
`${window.location.origin}${window.location.pathname}?action=${prefix}Proxy`;
const proxyReq = req.clone({
method: 'POST',
url: proxyUrl,
body: {
url: req.url,
method: req.method,
headers: {} as Record<string, string>,
body: req.body ? JSON.stringify(req.body) : '',
},
});
return next(proxyReq);
};
This interceptor is transparent -- when you run ng serve locally for development, external API calls go directly. When running inside CCO, they go through the proxy automatically.
Talking to the POS -- Create a service that wraps the POSBridge SDK. In the FoodInfo demo, the embedded view subscribes to ReceiptStore changes to automatically look up the barcode of the selected item:
ngOnInit(): void {
const receiptStore = this.pos.store('ReceiptStore');
this.pos.ready$.subscribe(async () => {
const selectedItem = await receiptStore.getSelectedItem();
this.lookup(selectedItem?.material?.gtin);
});
receiptStore.subscribe(async () => {
const selectedItem = await receiptStore.getSelectedItem();
this.lookup(selectedItem?.material?.gtin);
});
}
The store access API is generic - you can call any method on any CCO store without needing changes to the bridge code.
To open the popup view from the embedded view, push the prefixed show event:
openPopup(): void {
this.pos.pushEvent('FOODINFO_SHOW_WEBVIEW', { gtin: this.barcode() });
}
Step 4: Configure the embedded view in CCO
Add the embedded iframe to a Quickselection or layout using DynamicProperties. The key follows the pattern {PREFIX}_WEBVIEW_EMBEDDED:
{
"complex": {
"component": "ContainerComponent",
"props": {
"static": {},
"dynamic": "#dynamicProperties:FOODINFO_WEBVIEW_EMBEDDED"
}
}
}
Step 5: Build and deploy
mvn clean package
This compiles the Java code, builds the Angular app, and bundles everything into a single JAR. Copy the JAR into the CCO plugins directory (using an isolated classloader subfolder like CL_foodinfo-plugin/ is recommended) and restart SAP Customer Checkout.
Screenshots


Development workflow
One of the biggest advantages of this approach is the development experience. You can run ng serve (or the equivalent for your framework) and work on the UI with hot-reload at http://localhost:4200. The views work standalone for UI development -- bridge calls simply won't connect until you run inside the POS. This makes iterating on the frontend much faster than the traditional CCO plugin development cycle.
What's included in the library
AbstractWebAppBridgePlugin-- the base class handling all the bridge mechanicscco-web-app-bridge.js-- templated JavaScript that runs in the POS parent window, managing iframe lifecycle, postMessage routing, and event bus integrationpos-bridge-sdk.js-- the client SDK for use inside your SPA, providingPOSBridge, store access, and event handling- Generic store access -- call any CCO store method and subscribe to changes without modifying bridge code
- Event bus integration -- subscribe to any CCO event bus event from your SPA
- CORS proxy -- make external API calls from within the iframe through the plugin servlet
- Embedded and popup modes -- show your app inline or as a full-screen overlay
- Remote mode -- for development or trusted internal apps, proxy a remote web app instead of bundling it
What's next
I plan to keep extending the library based on feedback. If you build something with it, I'd love to hear about it. Some areas I'm looking into:
- More demo plugins for different use cases
- Additional documentation and guides
The library is licensed under LGPL v3, the FoodInfo demo under MIT. Contributions and feedback are welcome.
Give it a try and let me know what you think -- I'm curious what use cases the community comes up with!