Compatibility Issue: SuperPWA Conflicts with PNFPB Push Notification Plugin
One user reported that SuperPWA and the PNFPB Push Notification plugin both generate their own service workers, causing a conflict because the browser can only register one at a time. Due to this, push notifications from the BuddyPress-related plugin stop working. The user is requesting SuperPWA to add compatibility so that the push notification logic of the PNFPB plugin can function alongside SuperPWA.
Reference Ticket: https://wordpress.org/support/topic/push-notification-for-post-and-buddypress/
My Name is Murali, I am author of PNFPB plugin. I just want to clarify with more details,
Latest version of PNFPB plugin is in WordPress repository https://wordpress.org/plugins/push-notification-for-post-and-buddypress/
superPWA plugin generates its own service worker containing code for PWA cache.
This plugin PNFPB also generates service worker containing code for push notification as well as PWA.
At a time, browser will take only one service worker to be active. Since both plugin generates service worker according to their functionality, it will create conflict.
if superPWA provides service worker with general push notification logic to receive and display notification then i will be modify this plugin to user super PWA service worker in this plugin to send notification for BuddyPress options also like how I integrated OneSignal in this plugin.
Super PWA supports another push notification logic (from pushnotifications.io) which has different functionality which uses different Firebase credentials for push notification. No api facility to connect to pushnotifications.io provider in superPWA. Due to this problem, i think it is not possible to integrate PNFPB with superPWA. If superPWA or superPWA’s push notification provider provides api facility to integrate like Onesignal/Progressier then i will be able to integrate PNFPB with superPWA, so that it uses correct service worker, we will be able to send BuddyPress push notifications by integrating superPWA.
Service worker of PNFPB will look like as in below code, it contains code to receive notification and to display push notifications as shown in below code, service worker file of PNFPB plugin - pnfpb_icpush_pwa_sw.js.
`'use strict';
//console.log(pnfpb_ajax_object_sw.nonce);
var pnfpb_progressier_app_enabled = '';
var pnfpb_hide_foreground_notification = '';
var pnfpb_sw_token = {};
var pnfpb_ic_fcm_turnonoff_delivery_notifications = '';
var pnfpb_progressier_app_id = '';
if (pnfpb_progressier_app_enabled === '1' && pnfpb_progressier_app_id != '' ) {
var pnfpb_progressier_sw_filename = "https://progressier.app/"+pnfpb_progressier_app_id+"/sw.js";
importScripts(pnfpb_progressier_sw_filename);
}
var isPWAenabled = '1';
var isExcludeallurlsincache = '1';
// Config
var OFFLINE_ARTICLE_PREFIX = 'pnfpb-offline--';
var SW = {
cache_version: 'pnfpb_v3.10.1',
offline_assets: []
};
if (isExcludeallurlsincache === '1' || isExcludeallurlsincache === 'no') {
caches.delete(SW.cache_version);
}
if (isPWAenabled === '1') {
//This is the "Offline copy of pages" wervice worker
//Install stage sets up the index page (home page) in the cahche and opens a new cache
var cacheurl3 = '';
var cacheurl4 = '';
var cacheurl5 = '';
if (isExcludeallurlsincache !== '1' && isExcludeallurlsincache !== 'no') {
SW.offline_assets.push("http://Home%20page");
if (cacheurl3 !== '' && cacheurl3 !== 'http://Home%20page' && cacheurl3 !== cacheurl4 && cacheurl3 !== cacheurl5){
SW.offline_assets.push(cacheurl3);
}
if (cacheurl4 !== '' && cacheurl4 !== 'http://Home%20page' && cacheurl3 !== cacheurl4 && cacheurl4 !== cacheurl5){
SW.offline_assets.push(cacheurl4);
}
if (cacheurl5 !== '' && cacheurl5 !== 'http://Home%20page' && cacheurl5 !== cacheurl3 && cacheurl4 !== cacheurl5){
SW.offline_assets.push(cacheurl5);
}
}
const offlinePage = "http://Home%20page";
var pnfpbwpSysurls = ['gstatic.com','/wp-admin/','/wp-json/','/s.w.org/','/wp-content/','/wp-login.php','/wp-includes/','/preview=true/','ps.w.org'];
var pnfpbexcludeurls = "";
var pnfpbexcludeurlsarray = pnfpbexcludeurls.split(",");
var neverCacheUrls = pnfpbwpSysurls;
if (pnfpbexcludeurlsarray.length > 0 && pnfpbexcludeurls !== ''){
neverCacheUrls = pnfpbwpSysurls.concat(pnfpbexcludeurlsarray);
}
//
// Installation
//
self.addEventListener('install', (event) => {
// Don't wait to take control.
//console.log('skip waiting...service worker install');
event.waitUntil(self.skipWaiting());
// Set up our cache.
if (isExcludeallurlsincache !== '1' && isExcludeallurlsincache !== 'no') {
event.waitUntil(
caches.open(SW.cache_version).then(function(cache) {
// Attempt to cache assets
var urlsToCache = SW.offline_assets || [];
return Promise.all(
urlsToCache.map(url =>
cache.add(url).catch(err => {
console.warn('Service Worker: Failed to cache', url, err);
})
)
).then(() => {
console.log('Service Worker: Installation completed with some files possibly skipped.');
});
}).then(function(e){
return self.skipWaiting();
})
);
}
});
//
// Activation. First-load and also when a new version of SW is detected.
//
self.addEventListener('activate', function(event) {
// Delete all caches that aren't named in SW.cache_version.
//
event.waitUntil(clients.claim());
var expectedCacheNames = [SW.cache_version];
event.waitUntil(
caches.keys().then(function(cacheNames) {
return Promise.all(
cacheNames.map(function(cacheName) {
// Two conditions must be met in order to delete the cache:
//
// 1. It must NOT be found in the main SW cache list.
// 2. It must NOT be prefixed with our offline article prefix.
if (
expectedCacheNames.indexOf(cacheName) === -1 &&
cacheName.indexOf(OFFLINE_ARTICLE_PREFIX) === -1
) {
// If this cache name isn't present in the array of "expected"
// cache names, then delete it.
console.info('Service Worker: deleting old cache ' + cacheName);
return caches.delete(cacheName);
}
})
);
})
);
});
//
// Intercept requests
//
self.addEventListener('fetch', function(event) {
// Return if the current request url is in the never cache list
if ( ! neverCacheUrls.every(checkNeverCacheList, event.request.url) ) {
//console.log( 'Service worker - request is excluded from cache.' );
return;
}
if (isExcludeallurlsincache === '1' || isExcludeallurlsincache === 'no') {
//console.log( 'Service worker - request is excluded from cache.' );
return;
}
// Build a hostname-free version of request path.
//console.log(event.request.url);
var reqLocation = getLocation(event.request.url);
var reqPath = '';
var updateCache = function(request){
return caches.open(SW.cache_version).then(function (cache) {
return fetch(request).then(function (response) {
//console.log('[pnfpbpwa] add page to offline '+response.url)
return cache.put(request, response.clone());
})
.catch(function () {
return caches.match(offlinePage);
})
});
};
event.waitUntil(updateCache(event.request));
var request = event.request;
// Always fetch non-GET requests from the network
if (request.method !== 'GET') {
event.respondWith(
fetch(request)
.catch(function () {
return caches.match(offlinePage);
})
);
return;
}
// Consolidate some conditions for re-use.
var requestisAccept = false;
if (event.request.headers.get('Accept')) {
if (event.request.headers.get('Accept').indexOf('text/html') !== -1){
requestisAccept = true;
}
}
var requestIsHTML = event.request.method === 'GET';
// Saved articles, MVW pages, Offline
//
// First, we check to see if the user has explicitly cached this HTML content
// or if the page is in the "minimum viable website" list defined in the main
// SW.cache_version. If no cached page is found, we fallback to the network,
// and finally if both of those fail, serve the "Offline" page.
if (
requestisAccept && requestIsHTML
) {
event.respondWith(
caches.match(event.request).then(function (response) {
// Show old content while revalidating URL in background if necessary.
return staleWhileRevalidate(event.request);
}).catch(function(error) {
// When the cache is empty and the network also fails, we fall back to a
// generic "Offline" page.
return caches.match(offlinePage);
})
);
}
else {
event.respondWith(
fetch(request)
.catch(function () {
return caches.match(offlinePage);
})
);
return;
}
});
}
else
{
//
// Activation. First-load and also when a new version of SW is detected.
//
self.addEventListener('activate', function(event) {
// Delete all caches that aren't named in SW.cache_version.
//
var expectedCacheNames = [SW.cache_version];
event.waitUntil(
caches.keys().then(function(cacheNames) {
return Promise.all(
cacheNames.map(function(cacheName) {
// If this cache name isn't present in the array of "expected"
// cache names, then delete it.
console.info('Service Worker: deleting old cache ' + cacheName);
return caches.delete(cacheName);
})
);
})
);
});
}
// Stale While Revalidate
//
// Helper function to manage cache updates in the background.
function staleWhileRevalidate(request) {
// Build a hostname-free version of request path.
var reqLocation = getLocation(request.url);
var reqPath = reqLocation.pathname;
// Open the default cache and look for this request. We have to restrict this
// lookup to one cache because we want to make sure we don't add new entries
// unless really necessary (third-party assets, unsaved content, etc).
var defaultCachePromise = caches.open(SW.cache_version);
var defaultMatchPromise = defaultCachePromise.then(function(cache) {
return cache.match(request);
});
// Find any user-saved articles so we can update outdated content.
var userCachePromise = caches.has(OFFLINE_ARTICLE_PREFIX + reqPath).then(function maybeOpenCache(cacheExists) {
// This conditional exists because, per spec, caches.has() resolves whether
// the cache is found or not. The Promise value returns true or false based
// on whether the cache was found. Rejections only occur when something
// exceptional has occurred, not just because a cache is missing.
//
// @see https://www.w3.org/TR/service-workers/#cache-storage-has
//
// In cases where the cache was NOT found, I had extreme difficulty getting
// pages to load, since manually rejecting caused the Promise.all() below
// to fail, resulting in the Offline page even when something more useful
// should have displayed.
//
// My band-aid is to load the main cache when no user cache was found,
// sending along a similar object that won't ever be touched again since
// the userMatchPromise will never match content URLs in the main cache.
if (cacheExists) {
return caches.open(OFFLINE_ARTICLE_PREFIX + reqPath);
} else {
return caches.open(SW.cache_version);
}
}).catch(function () {
console.error('Error while trying to load user cache for ' + reqPath);
});
var userMatchPromise = userCachePromise.then(function matchUserCache(cache) {
return cache.match(request);
});
return Promise.all([defaultCachePromise, defaultMatchPromise, userCachePromise, userMatchPromise]).then(function(promiseResults) {
// When ES2015 isn't behind a flag anymore, move these vars to an array
// in the function signature to destructure the results of the Promise.
var defaultCache = promiseResults[0];
var defaultResponse = promiseResults[1];
var userCache = promiseResults[2];
var userResponse = promiseResults[3];
// Determine whether any cache holds data for this request.
var requestIsInDefaultCache = typeof defaultResponse !== 'undefined';
var requestIsInUserCache = typeof userResponse !== 'undefined';
// Kick off the update request in the background.
var fetchResponse = fetch(request).then(function(response) {
// Determine whether this is first or third-party request.
var requestIsFirstParty = response.type === 'basic';
// IF the DEFAULT cache already has an entry for this asset,
// AND the resource is in our control,
// AND there was a valid response,
// THEN update the cache with the new response.
if (requestIsInDefaultCache && requestIsFirstParty && response.status === 200) {
// Cache the updated file and then return the response
defaultCache.put(request, response.clone());
console.info('Service worker - Fetch listener updated ' + reqPath);
}
// IF the USER cache already has an entry for this asset,
// AND the resource is in our control,
// AND there was a valid response,
// THEN update the cache with the new response.
else if (requestIsInUserCache && requestIsFirstParty && response.status === 200) {
// Cache the updated file and then return the response
userCache.put(request, response.clone());
console.info('Service worker - Fetch listener updated ' + reqPath);
}
// None of the conditions were met. Just skip the caching phase.
else {
//console.info('Service worker - Fetch listener skipped ' + reqPath);
}
// Return response regardless of caching outcome.
return response;
});
// Return any cached responses if we have one, otherwise wait for the
// network response to come back.
return defaultResponse || userResponse || fetchResponse;
});
}
// Check if current url is in the neverCacheUrls list
function checkNeverCacheList(url) {
if ( this.match(url) ) {
return false;
}
return true;
}
// Polyfill for window.location
//
function getLocation(href) {
var match = href.match(/^(https?\:)\/\/(([^:\/?#]*)(?:\:([0-9]+))?)(\/[^?#]*)(\?[^#]*|)(#.*|)$/);
return match && {
protocol: match[1],
host: match[2],
hostname: match[3],
//port: match[4],
pathname: match[5],
search: match[6],
hash: match[7]
};
}
async function pnfpb_send_delivery_confirmation(notification_id,notification_token,notify_type) {
/* Get required details for fetch api from Indexeddb */
/* Encrypt using AES-256-GCM method to send it in API */
/* After data sent using API, Server then decrypts using AES-256-GCM method */
/* to update Notification delivery and read counts in PNFPB Plugin's WordPress database tables */
const PNFPB_SW_request = indexedDB.open("PNFPB_SW_Database");
PNFPB_SW_request.onsuccess = (event) => {
const PNFPB_SW_db = event.target.result;
const PNFPB_SW_transaction = PNFPB_SW_db.transaction(["PNFPB_SW_Store"], "readonly");
const PNFPB_SW_objectStore = PNFPB_SW_transaction.objectStore("PNFPB_SW_Store");
const PNFPB_SW_getRequest = PNFPB_SW_objectStore.get(1);
PNFPB_SW_getRequest.onsuccess = async () => {
const data = PNFPB_SW_getRequest.result;
if (data && data.pnfpb_auth_token) {
if (data.pnfpb_auth_token !== '') {
// concatenate data to be encrypted delimited with "@!!@" which will be decrypted in server side
// and split to appropriate fields using same delimiter "@!!@"
var pnfpb_subscription_token = '';
if (data.subscription_token) {
pnfpb_subscription_token = data.subscription_token;
}
const pnfpb_data_to_be_encrypted = notification_id+'@!!@'+pnfpb_subscription_token+'@!!@'+navigator.userAgent+'@!!@'+notify_type;
const notification_delivery_encrypted_data = await pnfpb_encryptData(pnfpb_data_to_be_encrypted,data.pnfpb_auth_token);
const notification_delivery_encrypted_authtoken = await pnfpb_encryptData(notification_token,data.pnfpb_auth_token);
const PNFPB_SW_rest_token_transaction = PNFPB_SW_db.transaction(["PNFPB_SW_rest_token_Store"], "readonly");
const PNFPB_SW_rest_token_objectStore = PNFPB_SW_rest_token_transaction.objectStore("PNFPB_SW_rest_token_Store");
const PNFPB_SW_rest_token_getRequest = PNFPB_SW_rest_token_objectStore.get(1);
PNFPB_SW_rest_token_getRequest.onsuccess = async () => {
const data = PNFPB_SW_rest_token_getRequest.result;
if (data && data.pnfpb_rest_token && data.pnfpb_rest_token !== '') {
const notification_delivery_details = { encrypted_data: notification_delivery_encrypted_data, pnfpb_encrypted_authtoken: notification_delivery_encrypted_authtoken };
/* Send notification delivery counts and read counts with encrypted signature using AES-256-GCM using secured PNFPB REST API */
fetch('https://cdn75.indiacities.in/wp-json/PNFPBpush/v2/notification-delivery-counts/', { method: 'POST', headers: {'Content-Type': 'application/json',
},body: JSON.stringify(notification_delivery_details) })
.then(async response => {
// Check if the request was successful (e.g., status code 200-299)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
// Parse the response body as JSON
//const responsejson = await response.json();
return '';
})
.catch(error => {
// Handle any errors that occurred during the fetch operation
console.error('There was a problem with the fetch operation:', error);
return '';
});
} else {
console.log('Notification delivery and read count not sent - PNFPB_SW_rest_token_Store tokens not found');
return '';
}
}
} else {
console.log('Notification delivery and read count not sent - PNFPB_SW_Store tokens not found');
return '';
}
} else {
return '';
}
};
PNFPB_SW_getRequest.onerror = (event) => {
reject('Error getting data: ' + event.target.error);
PNFPB_SW_db.close();
return '';
};
};
PNFPB_SW_request.onerror = (event) => {
console.error("Database error:", event.target.errorCode);
return '';
};
}
async function pnfpb_encryptData(plaintext, passphrase) {
const ptUtf8 = new TextEncoder().encode(plaintext);
const pwUtf8 = new TextEncoder().encode(passphrase);
const pwHash = await crypto.subtle.digest('SHA-256', pwUtf8);
const iv = crypto.getRandomValues(new Uint8Array(16));
const alg = { name: 'AES-GCM', iv: iv };
const key = await crypto.subtle.importKey('raw', pwHash, alg, false, ['encrypt']);
const ctBuffer = await crypto.subtle.encrypt(alg, key, ptUtf8);
const tag = new Uint8Array(ctBuffer, ctBuffer.byteLength - 16, 16);
const ciphertext = new Uint8Array(ctBuffer, 0, ctBuffer.byteLength - 16);
const ivHex = Array.from(iv).map(b => ('00' + b.toString(16)).slice(-2)).join('');
return {
ciphertext: btoa(String.fromCharCode(...new Uint8Array(ciphertext))),
iv: btoa(String.fromCharCode(...new Uint8Array(iv))),
tag: btoa(String.fromCharCode(...new Uint8Array(tag)))
}
}
async function pnfpb_receivePushNotification(event) {
event.stopImmediatePropagation();
var notification = {};
var pnfpb_push_data = {};
var notification_id = '';
var notification_token = '';
var push_notification_topic = '';
if (event.data) {
notification = event.data.json().notification;
pnfpb_push_data = event.data.json().data;
if (pnfpb_push_data.notification_id) {
notification_id = pnfpb_push_data.notification_id;
}
if (pnfpb_push_data.notification_auth_token) {
notification_token = pnfpb_push_data.notification_auth_token;
}
if (pnfpb_push_data.notification_firebase_topic) {
push_notification_topic = pnfpb_push_data.notification_firebase_topic;
}
// Customize notification here
const notificationTitle = notification.title;
const notificationOptions = {
body: notification.body,
icon: notification.icon,
image: notification.image,
data: {
url: notification.click_action,
notification_id:notification_id,
notification_token:notification_token,
},
tag: notification.tag,
renotify: notification.renotify
};
event.waitUntil(self.registration.showNotification(notificationTitle, notificationOptions));
if (notification_id !== '' && notification_token !== ''
&& (push_notification_topic === 'friendshipaccepted' || push_notification_topic === 'friendshiprequest'
|| push_notification_topic === 'markasfavourite' || push_notification_topic === 'privatemessages'
|| push_notification_topic === 'groupinvite'
|| push_notification_topic === 'onlytofriends' || push_notification_topic === 'ondemandselectedusers')
&& pnfpb_ic_fcm_turnonoff_delivery_notifications === '1') {
await pnfpb_send_delivery_confirmation(notification_id,notification_token,'delivery');
}
}
}
/* Push event process when push message received by browser */
self.addEventListener("push", pnfpb_receivePushNotification);
/* Event to be processed after notification is clicked to open client page/url */
self.addEventListener("notificationclick", (event) => {
event.preventDefault();
if (event.action === "read_more") {
event.notification.close();
// This looks to see if the current is already open and
// focuses if it is
event.waitUntil(clients.matchAll({
type: "window"
}).then((clientList) => {
for (const client of clientList) {
if (client.url === event.notification.data.url && 'focus' in client)
return client.focus();
}
if (clients.openWindow)
return clients.openWindow(event.notification.data.url);
}))
} else {
if (event.action === "custom_url") {
var pnfpb_custom_click_action_url = '';
event.notification.close();
// This looks to see if the current is already open and
// focuses if it is
event.waitUntil(clients.matchAll({
type: "window",
includeUncontrolled: true,
}).then((clientList) => {
for (const client of clientList) {
if (client.url === pnfpb_custom_click_action_url && 'focus' in client)
return client.focus();
}
if (clients.openWindow)
return clients.openWindow(pnfpb_custom_click_action_url);
}))
} else {
if (event.action === "close_notification") {
event.notification.close();
} else {
event.notification.close();
// This looks to see if the current is already open and
// focuses if it is
event.waitUntil(clients.matchAll({
type: "window",
includeUncontrolled: true,
}).then( async (clientList) => {
if (clients.openWindow) {
// Send a message to the client.
clients.openWindow(event.notification.data.url).then(async function(windowClient) {
if (event.notification.data.notification_id !== '' && event.notification.data.notification_token !== '' && pnfpb_ic_fcm_turnonoff_delivery_notifications === '1') {
await pnfpb_send_delivery_confirmation(event.notification.data.notification_id,event.notification.data.notification_token,'read');
return;
} else {
return;
}
})
}
}))
}
}
}
},
false,
);`
Also push notification in SUPERPWA did not support topic subscription and push notification to topic in Firebase. Firebase topic based push notification can handle more than 100,000 subscribers to send push notification in single attempt. PNFPB connects to Firebase api only one time to send notification to all topic based subscribers. PNFPB is using topic subscription and topic based push notifications which can send notification to more than 100,000 subscribers in single attempt (using Firebase topic) for notification sent from admin panel, post, custom post types, BuddyPress general activities, comments, avatar change, cover image change, new member joined , group invitation, group update. Similarly, SUPERPWA supports topic based Firebase push notification then it can be integrated with PNFPB plugin
If SUPERPWA needs more details in integrating PNFPB plugin, I will also provide more details.