Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
185 changes: 155 additions & 30 deletions public/sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,64 @@
/* eslint-disable no-unused-vars */
/* eslint-disable no-undef */
/* eslint-disable no-restricted-globals */
const version = 'v0.3.0';
const cacheName = 'simorghCache_v1';

const service = self.location.pathname.split('/')[1];
const hasOfflinePageFunctionality = false;
const OFFLINE_PAGE = `/${service}/offline`;

self.addEventListener('install', event => {
event.waitUntil(async () => {
const cache = await caches.open(cacheName);
if (hasOfflinePageFunctionality) await cache.add(OFFLINE_PAGE);
});
});

const version = 'v0.3.2';
// Update cache name when changing caching logic / changes in offlinepage.tsx
const cacheName = 'simorghCache_v2';
const pwaClients = new Map();
let isPWADeviceOffline = false;

// --------------------
// Helper Functions
// --------------------
const loggerEnabled = true;
const logger = (...args) => {
if (!loggerEnabled) return;
// eslint-disable-next-line no-console
console.log(`[SW ${version}]`, ...args);
};

const getServiceFromUrl = url => new URL(url).pathname.split('/')[1];
const getOfflinePageUrl = service => `/${service}/offline`;

const cacheResource = async (cache, url) => {
try {
const response = await fetch(url);
if (response.ok) await cache.put(url, response.clone());
return response;
} catch (err) {
logger(`Failed to cache ${url}:`, err);
return null;
}
};

const cacheOfflinePageAndResources = async service => {
const cache = await caches.open(cacheName);
const offlinePageUrl = new URL(
getOfflinePageUrl(service),
self.location.origin,
).href;

if (await cache.match(offlinePageUrl)) return;

const resp = await cacheResource(cache, offlinePageUrl);
if (!resp || !resp.ok) return;

logger(`Cached offline page for ${service}`);

const html = await resp.text();
const scriptSrcs = [
...html.matchAll(/<script[^>]+src=["']([^"']+)["']/g),
].map(m => m[1]);
const linkHrefs = [...html.matchAll(/<link[^>]+href=["']([^"']+)["']/g)].map(
m => m[1],
);

const resources = [...scriptSrcs, ...linkHrefs].filter(Boolean);

logger(`Caching final offline resources for ${service}:`, resources);
await Promise.allSettled(resources.map(url => cacheResource(cache, url)));
};

const CACHEABLE_FILES = [
// Reverb
Expand All @@ -35,24 +80,60 @@ const CACHEABLE_FILES = [
const WEBP_IMAGE =
/^https:\/\/ichef(\.test)?\.bbci\.co\.uk\/(news|images|ace\/(standard|ws))\/.+.webp$/;

// -------------Install event -------
self.addEventListener('install', () => {
logger(`Installing...`);
self.skipWaiting();
});

// -------Activate Handler-------------
self.addEventListener('activate', event => {
logger(`Activating...`);
event.waitUntil(
(async () => {
const keys = await caches.keys();
await Promise.all(
keys.map(key => key !== cacheName && caches.delete(key)),
);
await self.clients.claim();
})(),
);
});

// -------Message Event-------------
self.addEventListener('message', async event => {
logger('Message received:', event.data);

if (event.data?.type === 'PWA_STATUS') {
const clientId = event.source.id;
const { isPWA } = event.data;

if (isPWA) {
pwaClients.set(clientId, true);
const service = getServiceFromUrl(event.source.url);
await cacheOfflinePageAndResources(service);
}
}
});

// -------Fetch Handler-------------
const fetchEventHandler = async event => {
logger(`[SW FETCH] Request: ${event.request.url}`);
const isRequestForCacheableFile = CACHEABLE_FILES.some(cacheableFile =>
new RegExp(cacheableFile).test(event.request.url),
);

const isRequestForWebpImage = WEBP_IMAGE.test(event.request.url);
const isNavigationMode = event.request.mode === 'navigate';

if (isRequestForWebpImage) {
const req = event.request.clone();

// Inspect the accept header for WebP support

const supportsWebp =
req.headers.has('accept') && req.headers.get('accept').includes('webp');

// if supports webp is false in request header then don't use it
// if accept header doesn't indicate support for webp remove .webp extension

if (!supportsWebp) {
const imageUrlWithoutWebp = req.url.replace('.webp', '');
event.respondWith(
Expand All @@ -73,23 +154,67 @@ const fetchEventHandler = async event => {
return response;
})(),
);
} else if (hasOfflinePageFunctionality && event.request.mode === 'navigate') {
event.respondWith(async () => {
try {
const preloadResponse = await event.preloadResponse;
if (preloadResponse) {
return preloadResponse;
} else if (isNavigationMode) {
const { url } = event.request;

event.respondWith(
(async () => {
const client = await self.clients.get(event.clientId);
const isPWA = client && pwaClients.get(client.id);
const cache = await caches.open(cacheName);

// TODO: Used for testing - to be removed
logger('📌 [SW FETCH] Navigation', {
url: event.request.url,
clientId: event.clientId,
isPWA,
client,
event,
pwaClients,
isPWADeviceOffline,
});
try {
// Use preload if available
const preloadResp = await event.preloadResponse;
if (preloadResp) return preloadResp;

const networkResp = await fetch(event.request);
isPWADeviceOffline = false;
return networkResp;
} catch (err) {
// Only show offline page for installed PWA
if (isPWA) {
const service = getServiceFromUrl(url);
const offlineUrl = new URL(
getOfflinePageUrl(service),
self.location.origin,
).href;

const cachedOffline = await cache.match(offlineUrl);
if (cachedOffline) {
isPWADeviceOffline = true;
logger('[SW] Serving cached offline page');
return cachedOffline;
}
}
// fallback to browser default behavior
throw err;
}
const networkResponse = await fetch(event.request);
return networkResponse;
} catch (error) {
})(),
);
} else if (isPWADeviceOffline) {
logger(`[SW v${version}] Serving isPWADeviceOffline ${event.request.url}`);
event.respondWith(
(async () => {
const cache = await caches.open(cacheName);
const cachedResponse = await cache.match(OFFLINE_PAGE);
return cachedResponse;
}
});
const cached = await cache.match(event.request);
if (cached) return cached;
return fetch(event.request);
})(),
);
}

return;
};

onfetch = fetchEventHandler;
self.addEventListener('fetch', fetchEventHandler);
2 changes: 2 additions & 0 deletions src/app/components/ATIAnalytics/canonical/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import usePWAInstallTracker from '#app/hooks/usePWAInstallTracker';
import { reverbUrlHelper } from '@bbc/reverb-url-helper';
import useConnectionBackOnlineTracker from '#app/hooks/useConnectionBackOnlineTracker';
import useConnectionTypeTracker from '#app/hooks/useConnectionTypeTracker';
import usePWAOfflineTracking from '#app/hooks/usePWAOfflineTracking';
import { ATIAnalyticsProps } from '../types';
import getNoScriptTrackingPixelUrl from './getNoScriptTrackingPixelUrl';
import sendPageViewBeaconOperaMini from './sendPageViewBeaconOperaMini';
Expand Down Expand Up @@ -44,6 +45,7 @@ const CanonicalATIAnalytics = ({ reverbParams }: ATIAnalyticsProps) => {

useConnectionTypeTracker();
useConnectionBackOnlineTracker();
usePWAOfflineTracking();

const [reverbBeaconConfig] = useState(reverbParams);

Expand Down
107 changes: 47 additions & 60 deletions src/app/components/ServiceWorker/index.test.tsx
Original file line number Diff line number Diff line change
@@ -1,79 +1,38 @@
import onClient from '#app/lib/utilities/onClient';
import useServiceWorkerRegistration from '#app/hooks/useServiceWorkerRegistration';
import useSendPWAStatus from '#app/hooks/useSendPWAStatus';
import useIsPWA from '#app/hooks/useIsPWA';
import isLocal from '#app/lib/utilities/isLocal';
import { render } from '../react-testing-library-with-providers';
import { ServiceContext } from '../../contexts/ServiceContext';
import ServiceWorkerContainer from './index';
import { ServiceContext } from '../../contexts/ServiceContext';
import { render } from '../react-testing-library-with-providers';

jest.mock('#app/hooks/useServiceWorkerRegistration', () => jest.fn());
jest.mock('#app/hooks/useSendPWAStatus', () => jest.fn());
jest.mock('#app/hooks/useIsPWA', () => jest.fn());
jest.mock('#app/lib/utilities/isLocal', () => jest.fn());

const contextStub = {
swPath: '/articles/sw.js',
swPath: '/news/sw.js',
service: 'news',
};

const mockServiceWorker = {
register: jest.fn(),
};

jest.mock('#app/lib/utilities/onClient', () =>
jest.fn().mockImplementation(() => true),
);

jest.mock('#app/lib/utilities/isLocal', () =>
jest.fn().mockImplementation(() => true),
);

describe('Service Worker', () => {
const originalNavigator = global.navigator;

afterEach(() => {
jest.resetAllMocks();

global.navigator ??= originalNavigator;
describe('ServiceWorkerContainer', () => {
beforeEach(() => {
jest.clearAllMocks();
(useIsPWA as jest.Mock).mockReturnValue(false);
(isLocal as jest.Mock).mockReturnValue(true);
});

describe('Canonical', () => {
it('is registered when swPath, serviceWorker have values and onClient is true', () => {
// @ts-expect-error need to override the navigator.serviceWorker for testing purposes
global.navigator.serviceWorker = mockServiceWorker;
(onClient as jest.Mock).mockImplementationOnce(() => true);

it('calls service worker registration hook with service', () => {
render(
// @ts-expect-error only require a subset of properties on service context for testing purposes
<ServiceContext.Provider value={{ ...contextStub }}>
<ServiceContext.Provider value={contextStub}>
<ServiceWorkerContainer />
</ServiceContext.Provider>,
);
expect(navigator.serviceWorker.register).toHaveBeenCalledWith(
`/news/articles/sw.js`,
);
});

describe('is not registered', () => {
it.each`
swPath | serviceWorker | isOnClient
${undefined} | ${undefined} | ${true}
${undefined} | ${undefined} | ${false}
${undefined} | ${mockServiceWorker} | ${true}
${undefined} | ${mockServiceWorker} | ${false}
${contextStub.swPath} | ${mockServiceWorker} | ${false}
`(
'when swPath is $swPath, serviceWorker is $serviceWorker and isOnClient is $isOnClient',
({ swPath, serviceWorker, isOnClient }) => {
if (serviceWorker) {
// @ts-expect-error need to override the navigator.serviceWorker for testing purposes
global.navigator.serviceWorker = serviceWorker;
}

(onClient as jest.Mock).mockImplementationOnce(() => isOnClient);

render(
// @ts-expect-error only require a subset of properties on service context for testing purposes
<ServiceContext.Provider value={{ ...contextStub, swPath }}>
<ServiceWorkerContainer />
</ServiceContext.Provider>,
);
expect(navigator.serviceWorker.register).not.toHaveBeenCalled();
},
);
expect(useServiceWorkerRegistration).toHaveBeenCalledWith('news');
});
});

Expand Down Expand Up @@ -120,4 +79,32 @@ describe('Service Worker', () => {
);
});
});

describe('PWA', () => {
it('calls useSendPWAStatus with true when PWA is installed', () => {
(useIsPWA as jest.Mock).mockReturnValue(true);

render(
// @ts-expect-error only require a subset of properties on service context for testing purposes
<ServiceContext.Provider value={contextStub}>
<ServiceWorkerContainer />
</ServiceContext.Provider>,
);

expect(useSendPWAStatus).toHaveBeenCalledWith(true);
});

it('calls useSendPWAStatus with false when PWA is not installed', () => {
(useIsPWA as jest.Mock).mockReturnValue(false);

render(
// @ts-expect-error only require a subset of properties on service context for testing purposes
<ServiceContext.Provider value={contextStub}>
<ServiceWorkerContainer />
</ServiceContext.Provider>,
);

expect(useSendPWAStatus).toHaveBeenCalledWith(false);
});
});
});
Loading
Loading