I want to implement a user-clicked app update feature of a React PWA. I want to show a Chakra-UI toast which has a button, on whose click the PWA updates.
This is my service worker file code so far
// import { env } from 'env';
import { clientsClaim, RouteMatchCallback } from 'workbox-core';
import { createHandlerBoundToURL, precacheAndRoute } from 'workbox-precaching';
import { NavigationRoute, registerRoute } from 'workbox-routing';
import { NetworkOnly } from 'workbox-strategies';
import { Workbox } from 'workbox-window';
import { serverEndpoints } from '../api/endpoints';
import { platform } from './platformDetection';
import {
APPLICATION_JSON,
AUTHORIZATION,
CONTENT_TYPE,
DB,
ErrCodes,
deviceId,
SwMessageType,
} from './swConstants';
import {
factory,
getBody,
initToken,
json,
logger,
routeMatcher,
updateRequest,
} from './swUtils';
declare const self: ServiceWorkerGlobalScope;
// Connection to a broadcast channel
const bc = new BroadcastChannel('token_updates');
/** Buffer time between access token requests (4sec) */
const buffer = 4000 as const;
/**
* Access token timeout duration.
*
* Value is set as the app configuration api response (`access_token_expiry_in_seconds`)
*
* @defaultValue 15min
*/
let accessTokenTimeout = 15 * 60 * 1000 - buffer;
/**
* The id of setTimeout of access token.
* This is used to detect if there's a current timeout set for access token
*/
let timerId: NodeJS.Timer | undefined;
let isInitCalled = false;
/**
* Listens for the 'fetch' event and calls the `init()` function if it has not been called yet.
* This ensures that the service worker is properly initialized before handling any fetch requests.
*/
self.addEventListener('fetch', () => {
if (!isInitCalled) init();
});
const accessToken = initToken();
const refreshToken = initToken();
const subscription = initToken(); // initializing subscription token
clientsClaim();
self.skipWaiting();
precacheAndRoute(self.__WB_MANIFEST);
// initial setup for new worker
self.addEventListener('activate', init);
// Set up App Shell-style routing, so that all navigation requests
// are fulfilled with your index.html shell.
// https://developer.chrome.com/docs/workbox/modules/workbox-routing/#how-to-register-a-navigation-route
registerRoute(
new NavigationRoute(createHandlerBoundToURL('/index.html'), {
denylist: [//docs*/],
})
);
self.addEventListener('install', self.skipWaiting);
self.addEventListener('message', async (event) => {
const data = event.data;
if (!data) return;
switch (data.type as SwMessageType) {
case 'SKIP_WAITING':
self.skipWaiting();
break;
case 'fetchaccesstoken':
fetchAccessToken();
break;
default:
try {
await refreshToken;
await accessToken;
} catch (e) {
logger.error('default postMessage', e);
}
}
});
/** Local Login */
registerRoute(
routeMatcher('/login', location.origin),
factory(async () =>
json({
is_logged_in: Boolean(await refreshToken.catch(() => false)),
})
)
);
/** Local logout */
registerRoute(
routeMatcher('/logout', location.origin),
factory(async () => {
fetch(serverEndpoints.logout, {
headers: [[AUTHORIZATION, `Bearer ${await accessToken.catch(() => '')}`]],
method: 'PUT',
});
storeOrDeleteRefreshToken(true);
accessToken.update(Promise.reject(ErrCodes.LOGIN_REQUIRED));
refreshToken.update(Promise.reject(ErrCodes.LOGIN_REQUIRED));
return json({ message: 'logged out' });
})
);
// this route is specifically for ios but also can be used for android
registerRoute(
routeMatcher('/subscribe', location.origin),
factory(async (request) => {
const req = await request.json();
const existingSub = await self.registration.pushManager.getSubscription();
try {
if (existingSub) {
subscription.update(Promise.resolve(existingSub.toString()));
return json(existingSub);
}
const sub = await self.registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: req.vapid,
});
console.log('ios sub : ', sub);
subscription.update(Promise.resolve(sub.toString()));
return json(sub);
} catch (e: any) {
return new Response(JSON.stringify({ message: e.message }), {
status: 500,
headers: { [CONTENT_TYPE]: APPLICATION_JSON },
});
}
}),
'POST'
);
// Local refresh route for refreshing the access token and send push notification token to server
// we are passing stringified Push Subscription or fcm token in the device_id field
registerRoute(
routeMatcher('/refresh', location.origin),
factory(async (request) => {
let req = await request.json();
subscription.update(Promise.resolve(req.device_id));
let Body;
if (platform == 'ios') {
Body = JSON.stringify({ os: platform, device_id: req.device_id });
} else {
Body = JSON.stringify({ os: platform, device_id: req.device_id });
}
const res = await fetch(serverEndpoints.access_token, {
method: 'POST',
body: Body,
headers: new Headers([
[AUTHORIZATION, `Bearer ${await refreshToken}`],
[CONTENT_TYPE, APPLICATION_JSON],
]),
});
return res;
}),
'POST'
);
// Event listener for push notifications
self.addEventListener('push', async (event) => {
console.log('1pushevent', event);
const data = await event?.data?.json();
console.log('noteventdata', data);
let notificationTitle = data?.notification?.title || 'New Message Received';
let notificationBody = data?.notification?.body || '';
let notificationIcon = data?.notification?.image || '';
let notificationData = data?.data || {};
console.log('platform', platform);
if (platform === 'ios') {
notificationTitle = data?.title || notificationTitle;
notificationBody = data?.body || notificationBody;
notificationIcon = data?.image_url || notificationIcon;
notificationData = {
entity_type: data?.entity_type,
entity_id: data?.entity_id,
};
}
event.waitUntil(
self.registration.showNotification(notificationTitle, {
body: notificationBody,
icon: notificationIcon,
data: notificationData,
})
);
});
// self.addEventListener('push', function(event) {
// console.log('Push message received.');
// let notificationTitle = 'New Message Received';
// const notificationOptions = {
// body: 'Thanks for sending this push msg.',
// icon: '/public/Logo.svg',
// badge: '/public/Logo.svg',
// data: {
// url: 'https://web.dev/push-notifications-overview/',
// },
// };
// if (event.data) {
// const dataText = event.data.text();
// console.log("event.data",dataText)
// notificationTitle = 'Received Payload';
// notificationOptions.body = `Push data: '${dataText}'`;
// }
// event.waitUntil(
// self.registration.showNotification(
// notificationTitle,
// notificationOptions,
// ),
// );
// });
// Event listener for notification click
/**
* Event listener for notification click. When a notification is clicked, this function handles the navigation to the appropriate URL based on the notification data.
*
* @param {NotificationEvent} event - The notification click event.
* @returns {Promise<void>} - A promise that resolves when the navigation is complete.
*/
self.addEventListener('notificationclick', (event) => {
event.notification.close();
console.log('clickevent', event);
let url = `${location.origin}`;
console.log('surl', url);
let type = event.notification.data.entity_type;
let id = event.notification.data.entity_id;
switch (type) {
case 'chat':
url = url + '/chat-room/' + id;
break;
case 'trip':
url = url + '/home?tripId=' + id;
break;
case 'request':
url = url + '/home?tripId=' + id + '&viewRequest=true';
break;
default:
// Handle the default case if needed
break;
}
// Navigate to url
event.waitUntil(
self.clients
.matchAll({ type: 'window', includeUncontrolled: true })
.then((windowClients) => {
for (let client of windowClients) {
// if (client.url === url && 'focus' in client) {
// return client.focus();
// }
if (client.url.startsWith(location.origin)) {
// Refresh the window with the new URL
client.navigate(url);
return client.focus();
}
}
// If no existing window with the same URL is found, open a new one
return self.clients.openWindow(url);
})
);
});
// OTP route
registerRoute(routeMatcher('/auth/otp'), new NetworkOnly(), 'POST');
// LOGIN route
registerRoute(
routeMatcher('/auth/login'),
factory(async (request, handler) => {
const originalResponse = await handler.fetch(request);
const data: any = await getBody(originalResponse);
const a = data?.data?.access_token;
const r = data?.data?.refresh_token;
// save access and refresh tokens
if (typeof a === 'string' && typeof r === 'string') {
accessToken.update(Promise.resolve(a));
refreshToken.update(Promise.resolve(r));
storeOrDeleteRefreshToken();
// strip tokens from response before sending to client
return json({
...data,
data: {},
});
} else return originalResponse;
}),
'POST'
);
/** SWR routes.
* if `noCache` query param is passed, uses a network-only strategy and stores the result in cache,
* otherwise uses SWR strategy
*/
// registerRoute(
// ({ url }) =>
// cache.user.keys.includes(url.pathname as UserCacheKeys) &&
// url.origin === import.meta.env.VITE_API_BASE_URL,
// factory(
// async (request, handler) => {
// try {
// const url = new URL(request.url);
// const noCacheKey = 'noCache' satisfies keyof User.CacheControlParam;
// const noCache =
// 'true' ===
// (url.searchParams.get(
// noCacheKey
// ) as User.CacheControlParam['noCache']);
// url.searchParams.delete(noCacheKey);
// const req = changeGetRequestURL(url, request, {
// headers: addAuthHeader(request, await accessToken),
// });
// let res: Response;
// if (noCache) {
// res = await handler.fetchAndCachePut(req.clone());
// } else {
// res = await Promise.any([
// handler.fetchAndCachePut(req.clone()),
// handler.cacheMatch(req.clone()).then((res) => {
// if (!res) throw res;
// else return res;
// }),
// ]).catch((e) => {
// if (e instanceof AggregateError) return Response.error();
// else {
// logger.error('SWR error', e);
// throw e;
// }
// });
// }
// if (await isUnauthorized(res))
// res = await handler.fetchAndCachePut(req);
// if (url.pathname === cache.user.namedKeys.appConfiguration)
// setAccessTokenTimeoutDuration(res);
// return res;
// } catch (e) {
// if (e === ErrCodes.LOGIN_REQUIRED) {
// return json({ message: 'Login required' }, 401);
// }
// logger.error('swr error', e);
// }
// },
// { cacheName: cache.user.name }
// )
// );
/** Matches corider server origin routes */
const defaultApiRouteMatcher: RouteMatchCallback = ({ url }) => {
return url.origin === import.meta.env.VITE_API_BASE_URL;
};
/** Strategy for all corider apis, checks for 401 status code */
const defaultApiRouteHandler = factory(async (request, handler) => {
try {
const req = updateRequest(request, await accessToken);
const res = await handler.fetch(req.clone());
if (await isUnauthorized(res)) {
const req = updateRequest(request, await accessToken);
return await handler.fetch(req);
} else {
return res;
}
} catch (e) {
if (e === ErrCodes.LOGIN_REQUIRED) {
return json({ message: 'Login required' }, 401);
}
logger.error('defult route', e);
}
});
registerRoute(defaultApiRouteMatcher, defaultApiRouteHandler, 'GET');
registerRoute(defaultApiRouteMatcher, defaultApiRouteHandler, 'POST');
registerRoute(defaultApiRouteMatcher, defaultApiRouteHandler, 'PUT');
registerRoute(defaultApiRouteMatcher, defaultApiRouteHandler, 'DELETE');
registerRoute(defaultApiRouteMatcher, defaultApiRouteHandler, 'PATCH');
registerRoute(defaultApiRouteMatcher, defaultApiRouteHandler, 'HEAD');
/**
* Service Worker init function
*
* Creates IDB database and store if not already created,
*
* Fetches refresh token from DB if already existed, else throws.
*
* Fetches access token from network if refreh token is found.
*/
function init() {
isInitCalled = true;
logger.log('init called');
const request = self.indexedDB.open(DB.name, DB.version);
request.onblocked = (e) => {
logger.error('init blocked');
(e.target as IDBOpenDBRequest).result?.close();
};
request.onupgradeneeded = (e) => {
const db = request.result;
db.onerror = (e) => logger.error('Init db error ', e);
if (db.objectStoreNames.contains(DB.store.name))
db.deleteObjectStore(DB.store.name);
db.createObjectStore(DB.store.name, DB.store.options).transaction.commit();
};
request.onsuccess = (e) => {
const db = request.result;
logger.log('IDB open req success');
const transaction = db.transaction(DB.store.name, 'readonly');
const refreshTokenQuery = transaction
.objectStore(DB.store.name)
.get(DB.refreshToken.key);
refreshTokenQuery.onsuccess = async (e) => {
if (typeof refreshTokenQuery.result === 'string') {
logger.log('IDB ref success');
refreshToken.update(Promise.resolve(refreshTokenQuery.result));
fetchAccessToken();
} else {
refreshToken.update(Promise.reject(ErrCodes.LOGIN_REQUIRED));
accessToken.update(Promise.reject(ErrCodes.LOGIN_REQUIRED));
}
};
refreshTokenQuery.onerror = (e) => {
logger.error('refresh token query errror', e);
refreshToken.update(Promise.reject(ErrCodes.LOGIN_REQUIRED));
accessToken.update(Promise.reject(ErrCodes.LOGIN_REQUIRED));
};
};
request.onerror = (e) => {
logger.error('init error', e);
};
}
/**
* Stores refresh token into IDB.
*
* Deletes it if `true` is passed as paramater
*/
function storeOrDeleteRefreshToken(deleteToken?: boolean) {
const request = self.indexedDB.open(DB.name, DB.version);
request.onsuccess = async (e: Event) => {
const transaction = (e.target as IDBOpenDBRequest).result.transaction(
DB.store.name,
'readwrite'
);
if (deleteToken)
transaction.objectStore(DB.store.name).delete(DB.refreshToken.key);
else
transaction
.objectStore(DB.store.name)
.put(await refreshToken, DB.refreshToken.key);
transaction.commit();
};
request.onerror = (e) => {
logger.error('IDB error', e);
};
}
/** set a timeout for next access token fetch */
function setAccessTokenTimer(timeout: number) {
if (typeof timerId === 'undefined')
timerId = setTimeout(fetchAccessToken, timeout);
}
/**
* The only way access token should be used fetched.
*
* If the response has status `401`, rejects refresh token and access token, with {@link ErrCodes.LOGIN_REQUIRED} error code.
*
* If there was some other cause of failure (e.g. network failure), **resolves** the access token with garbage value
*/
async function fetchAccessToken() {
logger.log('fetching access token...');
clearTimeout(timerId);
timerId = undefined;
let resolver;
const _a = new Promise<string>((res) => void (resolver = res));
accessToken.update(_a, resolver);
try {
//using stored push subscription in each refresh request
const res = await fetch(serverEndpoints.access_token, {
method: 'POST',
body: JSON.stringify({
os: platform,
}),
headers: new Headers([
[AUTHORIZATION, `Bearer ${await refreshToken}`],
[CONTENT_TYPE, APPLICATION_JSON],
]),
});
if (res.status === 401) {
// refresh token rejected
refreshToken.update(Promise.reject(ErrCodes.LOGIN_REQUIRED));
// Navigate to url
let url = `${location.origin}/login`;
self.clients
.matchAll({ type: 'window', includeUncontrolled: true })
.then((windowClients) => {
for (let client of windowClients) {
// if (client.url === url && 'focus' in client) {
// return client.focus();
// }
if (client.url.startsWith(location.origin)) {
// Refresh the window with the new URL
client.navigate(url);
return client.focus();
}
}
// If no existing window with the same URL is found, open a new one
return self.clients.openWindow(url);
});
await refreshToken;
}
if (!res.ok) {
throw res;
}
const data = await res.json().catch(() => undefined);
if (typeof data?.access_token === 'string') {
accessToken.update(Promise.resolve(data.access_token));
bc.postMessage(data?.access_token);
setAccessTokenTimer(accessTokenTimeout);
} else
throw new Error("invalid server response, couldn't refresh access token");
} catch (e) {
// Couldn't fetch access token
logger.error('acc token', e);
if (e === ErrCodes.LOGIN_REQUIRED)
accessToken.update(Promise.reject(ErrCodes.LOGIN_REQUIRED));
else accessToken.update(Promise.resolve(''));
}
return true;
}
/** Sets the timeout duration for access token from the user config api response */
async function setAccessTokenTimeoutDuration(res: Response) {
const body: any = await getBody(res);
const tokenTimeout = body?.data?.access_token_expiry_in_seconds;
if (
typeof tokenTimeout === 'number' &&
!isNaN(tokenTimeout) &&
tokenTimeout !== 0
) {
accessTokenTimeout = tokenTimeout * 1000 - buffer;
}
}
/** Checks for `401` status code, and fetches the access token if it's not pending.
*
* This check must be used in every new RouteHandler/fetch event listener
*/
async function isUnauthorized(res: Response) {
if (res.status !== 401) return false;
if (accessToken.state === 'fulfilled') {
await fetchAccessToken();
}
return true;
}
I want to know a couple of things -:
- How to implement the toast coming feature on the React application. (I am using a Vite react app)
- I want to locally test out this feature. Any help in the testing of this app update feature would be extremely helpful.
I am a newbie with service workers and this is quite confusing for me.