Add PWA feature to UIBUILDER

Whilst previously using dashboard 2, I liked the PWA feature, where a number of files were cached locally to ensure speedy loading, and of course dashboards opened full screen. As of now though, I'm using UIbuilder, and have implemented PWA for my dashboards, so I'm posting the process so others may wish to use it also.
It works really well, and is fairly easy to implement, as all of the changes are made in your .node-red/uibuilder/<your project>/src/ directory

  1. add this to html head section in index.html;
<link rel="manifest" href="./manifest.json">
<meta name="theme-color" content="#003366">
  1. Create a manifest.json file containing this code, and place in ...src/
    Change the name & short name to your own site name, and optionally change the background & theme colors.
{
  "name": "Home Energy",
  "short_name": "Energy",
  "start_url": "./index.html",
  "display": "standalone",
  "background_color": "#000000",
  "theme_color": "#000000",
  "icons": [
  {
    "src": "icons/icon-512.png",
    "sizes": "512x512",
    "type": "image/png"
  },
  {
    "src": "icons/icon-192.png",
    "sizes": "192x192",
    "type": "image/png"
  }
]
}
  1. Create & add 2 icons to ...src/icons
    Easiest way I found was to ask ChatGPT to create an icon, according to your wishes. For example " Create an icon to represent home energy usage, using red and blue only, a modern stylish design with a transparent background". Then when you're happy, resize it to the sizes below, and place them in your /icons directory.
icon-192.png //Size 192px x 192px
icon-512.png //Size 512px x 512px
  1. Create a new file in .../src called service.worker.js containing the below;
const CACHE_NAME = 'uibuilder-cache-v1'
const ASSETS_TO_CACHE = [
  './index.html',
  './index.js',
  './index.css',
  './manifest.json',
  '../uibuilder/uibuilder.iife.min.js',
  '../uibuilder/uib-brand.min.css',
  './icons/icon-512.png',
  './icons/icon-192.png'
]

// Install event: cache static assets
self.addEventListener('install', event => {
  event.waitUntil(
    caches.open(CACHE_NAME).then(cache => {
      return cache.addAll(ASSETS_TO_CACHE)
    })
  )
})

// Activate event: cleanup old caches if needed
self.addEventListener('activate', event => {
  event.waitUntil(
    caches.keys().then(keys =>
      Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))
    )
  )
})

// Fetch event: respond with cache first, then network fallback
self.addEventListener('fetch', event => {
  event.respondWith(
    caches.match(event.request).then(cached => {
      return cached || fetch(event.request)
    })
  )
})
  1. and finally, add the below code to your uibuilder's index.js file;
if ('serviceWorker' in navigator) {
    window.addEventListener('load', () => {
        navigator.serviceWorker.register('./service-worker.js')
            .then(reg => console.log('Service worker registered:', reg.scope))
            .catch(err => console.error('Service worker error:', err))
    })
}

Refresh your browser, and you should (with most browsers!) have an invitation to install the dashboard as an app. (The icon shown is what AI created)

6 Likes

Great work Paul, many thanks for sharing.

For people who haven't yet tried this, there are lots of things you can do with a manifest file and service workers.

I am currently considering a future enhancement of UIBUILDER to automate the creation of the manifest & automatically load a manifest and service worker if they exist. It is possible to create a standard worker that will lazy load any resource within the ../uibuilder and ./ relative URL paths and automatically cache it for you for offline use. There would also be a manual override for specific paths so that you have full control.

Obviously, when offline, you will not receive and messages from Node-RED. However, using the uib-cache node, you can send any cached messages when a client reconnects. That already works out of the box.

There isn't currently a client-side message cache though I will consider that as well.

As always, speak up if there is a feature that you would like to see.


Also note that uibuilder includes quite a few icons at different sizes:

These can be accessed using a URL such as: ../uibuilder/images/maskable_icon_x512.png.

Here is a full manifest json example:

{
  "short_name": "Test",
  "name": "Testing UIBUILDER",
  "description": "We can break things if we try!",

  "scope": "./",
  "start_url": "./",
  "display": "standalone",

  "icons": [
    {
      "src": "../uibuilder/images/node-blue.svg",
      "type": "image/svg+xml",
      "sizes": "150x150"
    },
    {
      "src": "../uibuilder/images/maskable_icon_x192.png",
      "type": "image/png",
      "sizes": "192x192",
      "purpose": "maskable"
    },
    {
      "src": "../uibuilder/images/maskable_icon_x512.png",
      "type": "image/png",
      "sizes": "512x512",
      "purpose": "maskable"
    }
  ],

  "background_color": "#3367D6",
  "theme_color": "#3367D6"
}

You should have a minimum of a 192x192 and a 512x512 image. The SVG in the example should work at any size for those OS's that support SVG's as OS icons.

Lots of other things you can do, please check out the MDN site:

1 Like

As can be seen above, in the service.worker.js file, I'm calling the cache - const CACHE_NAME = uibuilder-cache-v1 and using the same cache name for all of my UIB instances (as I haven't used the SPA model, each page is a different instance).

This currently results in a single cache, and holds the data for my 3 instances - charger, energy & server.

Would it be more efficient to name the cache's differently uibuilder-cache-charger, uibuilder-cache-energy & uibuilder-cache-server, so each instance has it's own separate cache?

Interesting thought. I'm actually not sure. However, a quick ChatGPT has given some useful food for thought and suggests a hybrid approach that is probably sensible.

For example, you really don't want to have common resources in multiple caches. So uibuilder's core files (the client library and brand styles if you are using them) should go in 1 cache. But if you have specific resources only for 1 page, that should go in a separate cache.

This is one of MANY reasons that I don't think it is sensible for UIBUILDER to provide a pre-canned service worker other than creating a template one for you that you can then tweak to your own needs. It would not be possible to anticipate all of the possibilities and edge-cases without extremely complex logic which would probably fail as often as succeed and would certainly chew up resources unnecessarily.

What I'll probably do in light of this is to split the caches - core uibuilder files into 1, automatically cached (client library and brand css). Then a second targeting the "current" url path and probably also any vendor resources plus anything else you load.

Yes, sounds sensible :slightly_smiling_face:

This works OK...


const COMMON_CACHE = 'common-cache-v1'
const PAGE_CACHE = 'charger-cache-v1'

const COMMON_ASSETS = [
  '../uibuilder/uibuilder.iife.min.js',
  '../uibuilder/uib-brand.min.css'
]

const PAGE_ASSETS = [
  './index.html',
  './index.js',
  './index.css',
  './manifest.webmanifest',
  './icons/icon-512.png',
  './icons/icon-192.png'
]

// Install: Cache common and page assets
self.addEventListener('install', event => {
  event.waitUntil(
    Promise.all([
      caches.open(COMMON_CACHE).then(cache => cache.addAll(COMMON_ASSETS)),
      caches.open(PAGE_CACHE).then(cache => cache.addAll(PAGE_ASSETS))
    ])
  )
})

// Activate: Delete outdated caches
self.addEventListener('activate', event => {
  const allowedCaches = [COMMON_CACHE, PAGE_CACHE]
  event.waitUntil(
    caches.keys().then(keys =>
      Promise.all(keys.filter(k => !allowedCaches.includes(k)).map(k => caches.delete(k)))
    )
  )
})

// Fetch: Try page cache → common cache → network
self.addEventListener('fetch', event => {
  event.respondWith(
    caches.match(event.request).then(response => {
      return response || fetch(event.request)
    })
  )
})

...which results in;

and

1 Like