Skip to content

Commit

Permalink
feat,fix(JS): overhauled reconnection logic, add tab versioning
Browse files Browse the repository at this point in the history
  • Loading branch information
ncosta-ic committed May 16, 2024
1 parent 9812abe commit b5110b6
Show file tree
Hide file tree
Showing 2 changed files with 349 additions and 234 deletions.
221 changes: 124 additions & 97 deletions public/js/notifications-worker.js
Original file line number Diff line number Diff line change
@@ -1,48 +1,44 @@
/* Icinga Notifications Web | (c) 2024 Icinga GmbH | GPLv2 */
// noinspection JSUnresolvedReference

const _PREFIX = '[notifications-worker] - ';
const _SERVER_CONNECTIONS = {};
let _BASE_URL = '';
const VERSION = {
'WORKER': 1,
'SCRIPT': 1
};

const PREFIX = '[notifications-worker] - ';
const SERVER_CONNECTIONS = {};

self.console.log(PREFIX + "started worker on version " + VERSION.WORKER);

if (! (self instanceof ServiceWorkerGlobalScope)) {
throw new Error("Tried loading 'notification-worker.js' in a context other than a Service Worker.");
throw new Error("Tried loading 'notification-worker.js' in a context other than a Service Worker");
}

/** @type {ServiceWorkerGlobalScope} */
const selfSW = self;

selfSW.addEventListener('message', (event) => {
// self.console.log(_PREFIX + "received a message: ", event);
this.processMessage(event);
});
selfSW.addEventListener('activate', (event) => {
// claim all clients under own scope once the service worker gets activated
event.waitUntil(
selfSW.clients.claim().then(() => {
self.console.log(_PREFIX + "claimed all tabs.");
})
);
// claim all clients
event.waitUntil(selfSW.clients.claim().then());
});
selfSW.addEventListener('install', (event) => {
event.waitUntil(selfSW.skipWaiting().then());
})
selfSW.addEventListener('fetch', (event) => {
// self.console.log(_PREFIX + 'fetch event triggered with: ', event);
const request = event.request;
const url = new URL(event.request.url);

// only check dedicated event stream requests towards the daemon
if (request.headers.get('accept').startsWith('text/event-stream') && url.pathname.trim() === '/icingaweb2/notifications/daemon') {
if (_BASE_URL === '') {
self.clients.matchAll().then((clients) => {
clients[0].postMessage(JSON.stringify({
command: 'set_base_url'
}));
})
}

if (Object.keys(_SERVER_CONNECTIONS).length < 2) {
self.console.log(_PREFIX + `tab '${event.clientId}' requested event-stream.`);
if (Object.keys(SERVER_CONNECTIONS).length < 2) {
self.console.log(PREFIX + `tab '${event.clientId}' requested event-stream`);
event.respondWith(this.injectMiddleware(request, event.clientId));
} else {
self.console.log(_PREFIX + `event-stream request from tab '${event.clientId}' got blocked as there's already 2 active connections.`);
self.console.log(PREFIX + `event-stream request from tab '${event.clientId}' got blocked as there's already 2 active connections`);
// block request as the event-stream unneeded for now (2 tabs are already connected)
event.respondWith(new Response(
null,
Expand All @@ -69,29 +65,38 @@ selfSW.addEventListener('notificationclick', (event) => {
}
});

function processMessage(event) {
function processMessage (event) {
if (event.data) {
let data = JSON.parse(event.data);
switch (data.command) {
case 'set_base_url':
_BASE_URL = data.baseUrl;
self.console.log(_PREFIX + `set Icinga's base url to '${_BASE_URL}'`);
break;
case 'tab_force_reclaim':
/*
* trigger the claim process as there seems to be new clients in our scope which aren't under our
* control
*/
self.clients.claim().then(() => {
self.console.log(_PREFIX + "reclaimed all tabs.");
self.clients.matchAll().then((clients) => {
clients[0].postMessage(JSON.stringify({
command: 'set_base_url'
}));
})
});
case 'handshake':
if (data.version === VERSION.SCRIPT) {
self.console.log(
PREFIX
+ `accepting handshake from <tab: ${event.source.id}> <version: ${data.version}>`
);
event.source.postMessage(
JSON.stringify({
command: 'handshake',
status: 'accepted'
})
);
} else {
self.console.log(
PREFIX
+ `denying handshake from <tab: ${event.source.id}> <version: ${data.version}> as it does not `
+ `run the desired version: ${VERSION.SCRIPT}`
);
event.source.postMessage(
JSON.stringify({
command: 'handshake',
status: 'outdated'
})
);
}

break;
case 'tab_notification':
case 'notification':
/*
* displays a notification through the service worker (if the permissions have been granted)
*/
Expand Down Expand Up @@ -122,11 +127,11 @@ function processMessage(event) {
self.registration.showNotification(
title,
{
icon: _BASE_URL + '/img/notifications/icinga-notifications-' + severity + '.webp',
icon: data.baseUrl + '/img/notifications/icinga-notifications-' + severity + '.webp',
body: 'changed to severity ' + severity,
data: {
url:
_BASE_URL
data.baseUrl
+ '/notifications/incident?id='
+ notification.payload.incident_id
},
Expand All @@ -141,51 +146,72 @@ function processMessage(event) {
}
).then();
}

break;
case 'storage_toggle_update':
if (data.state) {
// notifications got enabled
// ask clients to open up stream
self.clients.matchAll({
type: 'window',
includeUncontrolled: false
}).then((clients) => {
let clientsToOpen = 2 - (Object.keys(_SERVER_CONNECTIONS).length);
if (clientsToOpen > 0) {
for (const client of clients) {
if (clientsToOpen === 0) {
break;
}
self.clients
.matchAll({type: 'window', includeUncontrolled: false})
.then((clients) => {
let clientsToOpen = 2 - (Object.keys(SERVER_CONNECTIONS).length);
if (clientsToOpen > 0) {
for (const client of clients) {
if (clientsToOpen === 0) {
break;
}

client.postMessage(JSON.stringify({
command: 'server_force_reconnect'
}));
--clientsToOpen;
client.postMessage(JSON.stringify({
command: 'open_event_stream',
clientBlacklist: []
}));
--clientsToOpen;
}
}
}
});
});
} else {
// notifications got disabled
// closing existing streams
self.clients.matchAll({
type: 'window',
includeUncontrolled: false
}).then((clients) => {
self.clients
.matchAll({type: 'window', includeUncontrolled: false})
.then((clients) => {
for (const client of clients) {
if (client.id in SERVER_CONNECTIONS) {
client.postMessage(JSON.stringify({
command: 'close_event_stream'
}));
}
}
});
}

break;
case 'reject_open_event_stream':
// adds the client to the blacklist, as it rejected our request
data.clientBlacklist.push(event.source.id);
self.console.log(PREFIX + `<tab: ${event.source.id}> rejected the request to open an event stream`);

selfSW.clients
.matchAll({type: 'window', includeUncontrolled: false})
.then((clients) => {
for (const client of clients) {
if (client.id in _SERVER_CONNECTIONS) {
if (! (data.clientBlacklist.includes(client.id)) && ! (client.id in SERVER_CONNECTIONS)) {
client.postMessage(JSON.stringify({
command: 'server_force_stop'
command: 'open_event_stream',
clientBlacklist: data.clientBlacklist
}));
return;
}
}
});
}

break;
}
}
}

async function injectMiddleware(request, clientId) {
async function injectMiddleware (request, clientId) {
// define reference holders
const controllers = {
writable: undefined,
Expand All @@ -204,35 +230,36 @@ async function injectMiddleware(request, clientId) {
signal: controllers.signal.signal
});
if (response.ok && response.status !== 204 && response.body instanceof ReadableStream) {
self.console.log(_PREFIX + `injecting into data stream of tab '${clientId}'.`);
self.console.log(PREFIX + `injecting into data stream of tab '${clientId}'`);
streams.readable = new ReadableStream({
start(controller) {
start (controller) {
controllers.readable = controller;

// stream opened up, adding it to the active connections
_SERVER_CONNECTIONS[clientId] = clientId;
SERVER_CONNECTIONS[clientId] = clientId;
},
cancel(reason) {
self.console.log(_PREFIX + `tab '${clientId}' closed event-stream (client-side).`);
cancel (reason) {
self.console.log(PREFIX + `tab '${clientId}' closed event-stream (client-side)`);

// request another opened up tab to take over the connection (if there's any)
self.clients.matchAll({
type: 'window',
includeUncontrolled: false
}).then((clients) => {
for (const client of clients) {
if (! (client.id in _SERVER_CONNECTIONS) && client.id !== clientId) {
client.postMessage(JSON.stringify({
command: 'server_force_reconnect'
}));
break;
self.clients
.matchAll({type: 'window', includeUncontrolled: false})
.then((clients) => {
for (const client of clients) {
if (! (client.id in SERVER_CONNECTIONS) && client.id !== clientId) {
client.postMessage(JSON.stringify({
command: 'open_event_stream',
clientBlacklist: []
}));

break;
}
}
}
});
});

// remove from active connections if it exists
if (clientId in _SERVER_CONNECTIONS) {
delete _SERVER_CONNECTIONS[clientId];
if (clientId in SERVER_CONNECTIONS) {
delete SERVER_CONNECTIONS[clientId];
}

// tab crashed or closed down connection to event-stream, stopping pipe through stream by
Expand All @@ -241,29 +268,29 @@ async function injectMiddleware(request, clientId) {
}
}, new CountQueuingStrategy({highWaterMark: 10}));
streams.writable = new WritableStream({
start(controller) {
start (controller) {
controllers.writable = controller;
},
write(chunk, controller) {
write (chunk, controller) {
controllers.readable.enqueue(chunk);
},
close() {
close () {
// close was triggered by the server closing down the event-stream
self.console.log(_PREFIX + `tab '${clientId}' closed event-stream (server-side).`);
self.console.log(PREFIX + `tab '${clientId}' closed event-stream (server-side)`);
// remove from active connections if it exists
if (clientId in _SERVER_CONNECTIONS) {
delete _SERVER_CONNECTIONS[clientId];
if (clientId in SERVER_CONNECTIONS) {
delete SERVER_CONNECTIONS[clientId];
}

// closing the reader as well
controllers.readable.close();
},
abort(reason) {
abort (reason) {
// close was triggered by an abort signal (most likely by the reader / client-side)
self.console.log(_PREFIX + `tab '${clientId}' closed event-stream (server-side).`);
self.console.log(PREFIX + `tab '${clientId}' closed event-stream (server-side)`);
// remove from active connections if it exists
if (clientId in _SERVER_CONNECTIONS) {
delete _SERVER_CONNECTIONS[clientId];
if (clientId in SERVER_CONNECTIONS) {
delete SERVER_CONNECTIONS[clientId];
}
}
}, new CountQueuingStrategy({highWaterMark: 10}));
Expand Down
Loading

0 comments on commit b5110b6

Please sign in to comment.