Can't get custom push notification event working in PWA (Firebase) - javascript

I've been searching for a few hours on how to get my custom push notification working. Here is how I've set up my project: no front-end framework, a Node.js/Express.js back-end, Firebase Cloud Messaging (FCM) as push manager and a custom service worker. I am currently hosting the app on localhost and I have HTTPS set up and a manifest.json that contains the minimum amount of fields to get started. The manifest.json contains a start_url field that points to /index.html, the landing page for the app. The app is bundled with webpack v. 4.
Back-end
On the back-end, I have set up the Firebase Admin SDK in a specific router and I send a custom notification object a bit like the following to the FCM server:
let fcMessage = {
data : {
title : 'Foo',
tag : 'url to view that opens bar.html'
}
};
When an interesting event occurs on the back-end, it retrieves a list of users that contains the FCM registration tokens and sends the notification to the FCM servers. This part works great.
Front-end
I have two service workers on the front-end. Inside my front-end index.js, I register a custom service worker named sw.js in the domain root and tell firebase to use that service worker like so:
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('./sw.js')
.then(registration => {
messaging.useServiceWorker(registration);
})
.catch(err => console.error(`OOps! ${err}`));
}
FCM and its credentials are set up and the user can subscribe to push notifications. I won't show that code here since it works and I don't believe it is the issue.
Now on to the service workers themselves. I have a firebase-messaging-sw.js file at the root of my domain. It contains the following code:
importScripts('https://www.gstatic.com/firebasejs/4.8.1/firebase-app.js');
importScripts('https://www.gstatic.com/firebasejs/4.8.1/firebase-messaging.js');
firebase.initializeApp(configuration);
const messaging = firebase.messaging();
Configuration is just a placeholder for all of the creds. Again that stuff works.
What I want to do is to NOT use the FCM push notification and instead create my own push notification that contains a url to a view that the user can click on and go to that view. The following code almost works and is a hack I found on another site:
class CustomPushEvent extends Event {
constructor(data) {
super('push');
Object.assign(this, data);
this.custom = true;
}
}
self.addEventListener('push', (e) => {
console.log('[Service Worker] heard a push ', e);
// Skip if event is our own custom event
if (e.custom) return;
// Keep old event data to override
let oldData = e.data;
// Create a new event to dispatch
let newEvent = new CustomPushEvent({
data: {
json() {
let newData = oldData.json();
newData._notification = newData.notification;
delete newData.notification;
return newData;
},
},
waitUntil: e.waitUntil.bind(e),
})
// Stop event propagation
e.stopImmediatePropagation();
// Dispatch the new wrapped event
dispatchEvent(newEvent);
});
messaging.setBackgroundMessageHandler(function(payload) {
if (payload.hasOwnProperty('_notification')) {
return self.registration.showNotification(payload.data.title,
{
body : payload.data.text,
actions : [
{
action : `${payload.data.tag}`,
title : 'Go to link'
}
]
});
} else {
return;
}
});
self.addEventListener('notificationclick', function(e) {
console.log('CLICK');
e.notification.close();
e.waitUntil(clients.matchAll({ type : 'window' })
.then(function(clientList) {
console.log('client List ', clientList);
const cLng = clientList.length;
if (clientList.length > 0) {
for (let i = 0; i < cLng; i++) {
const client = clientList[i];
if (client.url === '/' && 'focus' in client) {
return client.focus();
}
}
} else {
console.log('no clients ', e.action);
clients.openWindow(e.action)
.then(client => {
console.log('client ', client);
return client.navigate(e.action);
})
.catch(err => console.error(`[Service Worker] cannot open client : ${err} `));
}
}))
});
The hack is meant to capture a push event and the FCM default notification payload and instead serve that payload through a custom one made via the Notification API.
The code above works great but ONLY if I put it in the firebase-messaging-sw.js file. That's not what I really want to do: I want to put it in the sw.js file instead but when I do, the sw.js cannot hear any push events and instead I get the default FCM push notification. I've also tried importing the entire firebase-messaging-sw scripts into the custom service worker and it still won't hear the message events.
Why do I want to use it in my service worker instead of the Firebase one? It's to be able to open the app on the view passed into the 'tag' field on the notification's body. If I use the Firebase service worker, it tells me that it's not the active registered service worker and though the app does open in a new window, it only opens on /index.html.
Some minor observations I've made: the clients array is always empty when the last bit of code is added to the firebase-messaging-sw.js file. The custom service worker is installed properly because it handles the app shell cache and listens to all of the other events normally. The firebase-messaging-sw service worker is also installed properly.

After much pain and aggravation, I figured out what the problem was. It was a combination of the architecture of the app (that is, a traditional Multi-Page App) and a badly-formed url in the custom service worker, sw.js as so:
sw.js
self.addEventListener('fetch', e => {
// in this app, if a fetch event url calls the back-end, it contains api and we
// treat it differently from the app shell
if (!e.request.url.includes('api')) {
switch(e.request.url) {
case `${endpoint}/bar`: // <-- this is the problem right here
matcher(e, '/views/bar.html');
break;
case `${endpoint}/bar.js`:
matcher(e, '/scripts/bar.js');
break;
case `${endpoint}/index.js`:
matcher(e, '/index.js');
break;
case `${endpoint}/manifest.json`:
matcher(e, '/manifest.json');
break;
case `${endpoint}/baz/`:
matcher(e, '/views/bar.html');
break;
case `${endpoint}/baz.js`:
matcher(e, '/scripts/bar.js');
break;
default:
console.log('default');
matcher(e, '/index.html');
}
}
});
Matcher is the function that matches the request url with the file on the server. If the file already exists in the cache, it returns what is in the cache but if it doesn't exist in the cache, it fetches the file from the server.
Every time the user clicks on the notification, it's supposed to take him/her to the 'bar' html view. In the switch it must be:
case `${endpoint}/bar/`:
and not
case `${endpoint}/bar`:
Even though the message-related code is still in the firebase-messaging-sw.js file, what happens is it creates a new WindowClient when the browser is in the background. That WindowClient is under the influence of sw.js, not firebase-messaging-sw.js. As a result, when the window is opened, sw.js intercepts the call and takes over from firebase-messaging-sw.js.

Related

Is it possible to get the value of window.location.pathname from within a Service Worker?

I am trying to get the value from window.location.pathname (or a similar location read only API) inside the context of a ServiceWorker. I think one way to do that is sending that information from the page to the Service Worker via postMessage:
navigator.serviceWorker.ready.then( registration => {
registration.active.postMessage({
type: "pathname",
value: window.location.pathname
});
});
as seen in https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope/message_event
However, I need that data in the install step of the SW lifecycle so waiting on the SW to become the active one is not ideal, and I'd rather try first to get that data earlier so I can go thru the install step with that information.
Within the Service Worker, self.location is accessible via WorkerGlobalScope.location. You could listen to requests and process those that match the same origin of your domain.
self.addEventListener('fetch', event => {
const requestUrl = new URL(event.request.url)
if (self.location.origin === requestUrl.origin) {
const requestPathname = requestUrl.pathname
}
})

JavaScript Web Worker Messages [duplicate]

This question already has answers here:
How to allow Web Workers to receive new data while it still performing computation?
(5 answers)
Closed 2 years ago.
I need to have a JavaScript web worker that maintains its own internal state (not just replies to messages). It also needs to do be able to do a computation until it is told to stop (by a message being sent). Something like the following:
// worker.js
initialState = () => {...}
updateState = (state) => {...}
updateStateWithMessage = (state, message) => {...}
state = initalState()
state = updateStateWithMessage(state, self.getmessage())
while (true) {
while (!self.hasmessage()) {
state = updateState(state)
}
state = updateStateWithMessage(state, self.getmessage())
self.postmessage(state)
}
//main.js
worker = new Worker("worker.js")
worker.onmessage = (event) => console.log(event.data)
onClick() {
worker.postMessage("Here is some data.")
}
I couldn't think of a way to implement this with a single self.onmessage callback in the worker (which is how I have seen most examples of Web Workers operating) since it replies on maintaining its own internal state. So I invented the fictitious self.getmessage and self.hasmessage functions. Can anyone show me how to actually implement this or something similar.
Thanks in advance!
I'm not really sure if I understand your question.
If you want your web worker to be stateful and perform different tasks according to the message the main thread sent to the worker, you will need to implement your own messaging system. You will need to establish some kind of convention for your messages, and use some way to distinguish between the incoming messages, both in the main thread and in your web worker. Basically you need to implement a very lightweight version of Redux.
For example, I wrote a Three.js app where I render a bitmap in an OffscreenCanvas in a web worker.
First, I established a convention for the messages exchanged between main thread and worker. All my messages have a string representing the action the message is all about, a payload containing some optional data, and a source stating who sent the message (i.e. the main thread or the web worker).
In my app, the main thread can send 2 types of messages to the worker, while the worker can sen 3 types of messages.
// Actions sent by main thread to the web worker
export const MainThreadAction = Object.freeze({
INIT_WORKER_STATE: "initialize-worker-state",
REQUEST_BITMAPS: "request-bitmaps"
});
// Actions sent by the web worker to the main thread
export const WorkerAction = Object.freeze({
BITMAPS: "bitmaps",
NOTIFY: "notify",
TERMINATE_ME: "terminate-me",
});
My web worker is stateful, so in my worker.js file I have this code:
// internal state of this web worker
const state = {};
The worker can discern the messages sent by the main thread with a simple switch statement, handle the message, and respond to the main thread with postMessage:
// worker.js
const onMessage = event => {
const label = `[${event.data.source} --> ${NAME}] - ${event.data.action}`;
console.log(`%c${label}`, style);
switch (event.data.action) {
case MainThreadAction.INIT_WORKER_STATE:
// handle action and postMessage
break;
case MainThreadAction.REQUEST_BITMAPS: {
// handle action and postMessage
break;
}
default: {
console.warn(`${NAME} received a message that does not handle`, event);
}
}
};
onmessage = onMessage;
The code running in the main thread also uses a switch statement to distinguish between the messages sent by the worker:
// main.js
switch (event.data.action) {
case WorkerAction.BITMAPS: {
// handle action
break;
}
case WorkerAction.TERMINATE_ME: {
worker.terminate();
console.warn(`${NAME} terminated ${event.data.source}`);
break;
}
case WorkerAction.NOTIFY: {
// handle action
break;
}
default: {
console.warn(`${NAME} received a message that does not handle`, event);
}
}

How to make service worker register only on the first visit?

Hi I have an ejs template on express which generates HTML. I have written my service worker registration code in this template which is common for all pages of the website and thus, the registration code ends up being there on every page of the website. So, on every user visit, the service worker registration code is run which I believe is bad. How to make this code run only on the first visit of a user?
Please find my code below:
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', function() {
var hashes = {};
["appCss", "appJs"].map((val,idx) => {
let prop;
if(document.getElementById(val)) {
prop = val.toLowerCase().indexOf("css") == -1 ? "src" : "href";
hashes[val] = document.getElementById(val)[prop];
}
});
hashes = JSON.stringify(hashes);
navigator.serviceWorker
.register(`/service-worker.js?hash=${encodeURIComponent(hashes)}`)
.then(function(registration) {
console.log('ServiceWorker registration successful with scope: ', registration.scope);
}, function(err) {
console.log('ServiceWorker registration failed:', err);
});
});
}
</script>
From the MDN documentation :
The register() method of the ServiceWorkerContainer interface creates or updates a ServiceWorkerRegistration for the given scriptURL.
If successful, a service worker registration ties the provided script URL to a scope, which is subsequently used for navigation matching. You can call this method unconditionally from the controlled page. I.e., you don't need to first check whether there's an active registration.
So you should always call register on your page. The browser API will handle this for you.

How to read Client.postMessage before the page loaded?

I have a service worker that emits Client.postMessage during fetch when a cached resource has changed. I'm using this to notify the user that they might want to refresh.
My problem is that when the active page resource is changed and the service worker emits that message, the page hasn't loaded yet so no javascript can receive the message.
Is there a better way to handle cases like this rather than using waitUntil to pause a few seconds before emitting the message?
Another option would be to write to IndexedDB from the service worker, and then read it when the page loads for the first time, before you establish your message listener.
Using the ibd-keyval library for simplicity's sake, this could look like:
// In your service worker:
importScripts('https://unpkg.com/idb-keyval#2.3.0/idb-keyval.js');
async function notifyOfUpdates(urls) {
const clients = await self.clients.matchAll();
for (const client of clients) {
client.postMessage({
// Structure your message however you'd like:
type: 'update',
urls,
});
}
// Read whatever's currently saved in IDB...
const updatedURLsInIDB = await idb.get('updated-urls') || [];
// ...append to the end of the list...
updatedURLsInIDB.push(...urls);
// ...and write the updated list to IDB.
await idb.set('updated-urls', updatedURLsInIDB);
}
// In your web page:
<script src="https://unpkg.com/idb-keyval#2.3.0/idb-keyval.js"></script>
<script>
async listenForUrlUpdates() {
const updatedURLsInIDB = await idb.get('updated-urls');
// Do something with updatedURLsInIDB...
// Clear out the list now that we've read it:
await idb.delete('updated-urls');
// Listen for ongoing updates:
navigator.serviceWorker.addEventListener('message', event => {
if (event.data.type === 'update') {
const updatedUrls = event.data.urls;
// Do something with updatedUrls
}
});
}
</script>

JS Firebase: Service worker unable to nagivate

I am working on a website that uses firebase messaging and for this a custom service worker for firebase is registered to the website.
The website is somewhat a webchat that synchronizes and stores the messages in firebase database and the website uses angular-ui-router for getting the messages key from the url, the website shows a notification when a new message is received from the client and the url is modified for the related message if the notification was clicked, however this has been a struggle for the service worker side when the website is not on focus because the service worker throws an error saying unable to navigate
the service worker code is this:
importScripts('https://www.gstatic.com/firebasejs/4.5.0/firebase-app.js');
importScripts('https://www.gstatic.com/firebasejs/4.5.0/firebase-messaging.js');
firebase.initializeApp({
"messagingSenderId": "######"
});
const messaging = firebase.messaging();
messaging.setBackgroundMessageHandler(function(payload){
var title = payload.data.title;
var options = {
body: payload.data.message,
icon: "apple-touch-icon.png",
data: {
postId: payload.data.postKey
}
};
return self.registration.showNotification(title, options);
});
self.addEventListener("notificationclick", function(evt){
var postKey = evt.notification.data.postId;
var origin = evt.target.origin;
var urlTarget = origin + "/#!/" + postKey;
evt.notification.close();
evt.waitUntil(
self.clients.matchAll({
includeUncontrolled: true,
type: "window"
})
.then(function(clientList) {
if(clientList.length > 0){
var client = clientList[0];
client.navigate(urlTarget);
if(client.focus){
return client.focus();
}
}
if(self.clients.windowOpen){
return self.clients.windowOpen(urlTarget);
}
}));
});
the behavior i am expecting is as follow:
on focus:
Message received
Show notification
Modify url address when notification clicked
before notification click: https://example.com/#!/
after notification click: https://example.com/#!/-key
on non-focus:
Push received
Modify data for displaying notification
Show notification
Focus/Open website on notification click
Modify url address when focus/open
When focus/open: https://example.com/#!/-key
The code that shows the notification on focus works fine so i don't think that posting the code is necessary.
any help on this is appreciated, i have spent literally all the alternative options i have thought on this
As per the specification (see 4.2.10, step 4), one of the reasons why WindowClient.navigate() might reject is if the active service worker for the client is not the same as the current service worker that's executing the code. In other words, if the window isn't controlled by the current service worker, that service worker can't tell it to navigate to a given URL.
I'd recommend that you use
self.clients.matchAll({
includeUncontrolled: false,
type: 'window'
})
to ensure that you only get back WindowClient instances that are controlled by the current service worker.

Categories