I'm a developer & content creator, based in Ghent

High-level architecture of my point-of-sales app

High-level architecture of my point-of-sales app

In addition to my online food ordering app, I also developed an in-store Point-of-sales system that works on iPads, Android devices and Windows systems. In this blogpost, I’ll be diving into the high-level architecture of this application.

Visual learner? Check out the video instead:

Vue at the core

At the core of our POS app, sits a Vue SPA. For this application, I still use Vue 2.7 because over the years, our frontend component library grew to a substantial size, and it’s based on Bootstrap 4. The risk of using Vue 2 is pretty minimal, and if you need extended support, there are commercial NES versions - but for my use-case, I’m fine with accepting this minimal risk.

(That being said - for new projects I usually start with Vue 3 and Tailwind nowadays)

Vue app with IndexedDB as its database

As our database, we use IndexedDB with Dexie as a wrapper. This database is pretty lightweight and gets synced with our Laravel backend in the cloud. By default, Dexie uses auto-incrementing primary keys (like in MySQL), but because our POS app can be used by multiple waiters at the same time, who each can create their own tickets, we had to make use of UUIDs as a primary key.

To achieve this, I created my own Dexie plugin. This plugin hooks into the 'creating’ hook of a table, and will generate a UUID as a primary key whenever we insert a new record.

Our Vue app also connects to a websocket server, that our Laravel backend uses to dispatch events onto - for example when a new online order gets placed. This way, our POS app can register the orders instantly.

Electron & Node

So far so good, but this is where things get tricky.

Like most POS systems, our application is able to print receipts using a receipt printer. We could use WebUSB (my favorite browser API), but since our POS is built to be used mobile, we cannot use USB connections, as the waiters will not walk around the restaurant with a printer tethered to their tablets.

Unfortunately none of these printers have a decent SDK, especially when it comes to wireless communication using javascript in the browser. There are some printers that have an HTTP API, but most of these printers communicate using a low-level TCP protocol - and browsers do not allow direct TCP communication (for security reasons).

This is one of the reasons we had to introduce Electron into the mix.

Electron wraps our Vue SPA & adds a Node component

The Electron-specific code is super simple - we load our SPA into the webview, and we make use of their auto-update package.

Then, we have a node component, which I call our low-level gateway. This node gateway creates a websocket server, and through this websocket server, we’re able to communicate with our TCP printers.

Contrary to a classic HTTP request/response cycle, WebSockets don't 'wait' for a response. This is a bit unfortunate for our use-case, as we want to 'know' if the printer actually printed the receipt, or if it gave back an error (out of paper, lid open, ...). To mitigate this, I implemented a promise-based pattern, that allowed me to await the result of a print command as follows.

class WebsocketHelper {
    constructor(url) {
        this.socket = new WebSocket(url);
        this.requests = new Map();

        this.socket.onmessage = (event) => {
		        // Message from low-level gateway
            const message = JSON.parse(event.data);
            const { id, data } = message;

            if (this.requests.has(id)) {
                // Resolve the corresponding promise
                this.requests.get(id).resolve(data);
                this.requests.delete(id);
            }
        };
    }

    send(command) {
        return new Promise((resolve, reject) => {
            const id = Date.now(); // Unique ID for this request

            // Store the resolver so we can resolve it later
            this.requests.set(id, { resolve, reject });

            const message = JSON.stringify({ id, command });
            this.socket.send(message);
        });
    }
}

// Usage
(async () => {
    const ws = new WebsocketHelper('ws://example.com');

    ws.socket.onopen = async () => {
        try {
            const printSuccess = await ws.send('print');
            console.log('Response:', printSuccess);
        } catch (error) {
            console.error('Error:', error);
        }
    };
})();

Government regulation

Next-up, the government requires Belgian POS systems to be registered by law. This means that every single ticket needs to be stored in a thing called a Fiscal Data Module for tax auditability.

This law dates from a time where internet & mobile devices were not very popular - and the hardware reflects this, as it has no ethernet port and communication happens over a serial connection.

Nowadays, there’s a software server that provides an HTTP interface making things a bit easier, but this HTTP server was never intended to be used from within a browser. Things like CORS are unable to be configured, and this is why we had to introduce a reverse-proxy server within our low-level gateway. This proxy server proxies every request going to the fiscal data module server and adds the necessary headers.

Reverse-proxy for CORS

React Native

Finally - our application also works on mobile devices, but, since Electron doesn’t support this, we use React Native as a wrapper for our mobile apps.

Like Electron, the React Native specific code is pretty minimal, and simply loads the SPA into the webview.

React-Native for iOS & Android apps

Contrary to popular belief, React Native itself does not come with a NodeJS runtime. This means you cannot start an express server from within an app for example.

Lucky for us, there’s this beautiful project called NodeJS Mobile which is a full-fledged NodeJS runtime for iOS and Android apps. When adding NodeJS Mobile to an app, you get a NodeJS background worker. In this background worker, we run our low-level Node gateway, that is able to start up a Websocket server for printer communication, and our proxy server for communicating with the Fiscal Data Module.

NodeJS mobile comes with a small performance penalty on boot, and increases the binary size substantially (as you're bundling the entire NodeJS runtime) - but to me these are worthwhile tradeoffs as this allows me to share around 95% of my entire codebase across all of our supported platforms.

And that concludes the article - I hope this overview was helpful, and if it was, consider subscribing to my newsletter (no spam, only value!).

Subscribe to my monthly newsletter

No spam, no sharing to third party. Only you and me.