Intercept external URL fetch via ServiceWorker running on localhost - javascript

I have a set of integration tests which run in Karma. Unfortunately, they call out to an external, production API endpoint. I do not want integration tests to call out and am exploring my options.
I am wondering if service workers are a viable solution. My assumption is that they do not work because https://github.com/w3c/ServiceWorker/issues/1188 makes it clear that cross-origin fetch is not supported and localhost is not the same origin as a production API endpoint.
For clarity, here is some code I am running:
try {
const { scope, installing, waiting, active } = await navigator.serviceWorker.register('./base/htdocs/test/imageMock.sw.js');
console.log('ServiceWorker registration successful with scope: ', scope, installing, waiting, active);
(installing || waiting || active).addEventListener('statechange', (e) => {
console.log('state', e.target.state);
});
} catch (error) {
console.error('ServiceWorker registration failed: ', error);
}
and the service worker
// imageMock.sw.js
if (typeof self.skipWaiting === 'function') {
console.log('self.skipWaiting() is supported.');
self.addEventListener('install', (e) => {
// See https://slightlyoff.github.io/ServiceWorker/spec/service_worker/index.html#service-worker-global-scope-skipwaiting
e.waitUntil(self.skipWaiting());
});
} else {
console.log('self.skipWaiting() is not supported.');
}
if (self.clients && (typeof self.clients.claim === 'function')) {
console.log('self.clients.claim() is supported.');
self.addEventListener('activate', (e) => {
// See https://slightlyoff.github.io/ServiceWorker/spec/service_worker/index.html#clients-claim-method
e.waitUntil(self.clients.claim());
});
} else {
console.log('self.clients.claim() is not supported.');
}
self.addEventListener('fetch', (event) => {
console.log('fetching resource', event);
if (/\.jpg$/.test(event.request.url)) {
const response = new Response('<p>This is a response that comes from your service worker!</p>', {
headers: { 'Content-Type': 'text/html' },
});
event.respondWith(response);
}
});
and when this code is ran I see in the console
ServiceWorker registration successful with scope: http://localhost:9876/base/htdocs/test/ null null ServiceWorker
and then requests to https://<productionServer>.com/image.php are not intercepted by the fetch handler.
Is it correct that there is no way to intercept in this scenario?

You can use a service worker to intercept requests made by a browser as part of a test suite. As long a service worker is in control of a web page, it can intercept cross-origin requests and generate any response you'd like.
(The issue you link to about "foreign fetch" is something different; think of it as the remote production server deploying its own service worker. This was abandoned.)
"Stop mocking fetch" is a comprehensive article covering how to use the msw service worker library within the context of a test suite.
I can't say off the top of my head exactly why your current setup isn't working, but from past experience, I can say that the most important thing to remember when doing this is that you need to delay making any requests from a client test page until the page itself is being controlled by an active service worker. Otherwise, there's a race condition in which you might end up firing off a request that needs to trigger a fetch handler, but won't if the service worker isn't yet in control.
You can wait for this to happen with logic like:
const controlledPromise = new Promise((resolve) => {
// Resolve right away if this page is already controlled.
if (navigator.serviceWorker.controller) {
resolve();
} else {
navigator.serviceWorker.addEventListener('controllerchange', () => {
resolve();
});
}
});
await controlledPromise;
// At this point, the page will be controlled by a service worker.
// You can start making requests at this point.
Note that for this use case, await navigator.serviceWorker.ready will not give you the behavior you need, as there can be a gap in time between when the navigator.serviceWorker.ready promise resolves and when the newly activated service worker takes control of the current page. You don't want that gap of time to lead to flaky tests.

Related

How to load different files from cache?

I am using service worker to provide a fallback page that shows the user is offline. The service worker during interception of request, fetches the same request and on error on fetching, provides response for 'offline.html' request from the cache. A small snippet of doing this is.
self.addEventListener("fetch", (event) => {
event.respondWith(
caches.match(event.request).then(() => {
return fetch(event.request).catch((err) => {
return caches.match("offline.html");
});
})
);
});
now if the offline html has other request, probably to its css files, or images, how do I load them from cache. I've tried doing the following:
self.addEventListener("fetch", (event) => {
event.respondWith(
caches.match(event.request).then(() => {
return fetch(event.request).catch((err) => {
let url = event.request.url;
if(url.endsWith('.css')) return caches.match('offline.css');
if(url.endsWith('.jpg') || url.endsWith('.png')) return caches.match('images/banner.jpg');
return caches.match("offline.html");
});
})
);
});
But is there a better way of doing this? Is there a standard way of doing this?
First off, I would recommend checking to see whether event.request.destination === 'document' before you decide whether or not to use offline.html as the fallback content. That ensure that you're not accidentally returning an HTML document to satisfy, say, a random API request that happens to fail.
Additionally, your current code includes caches.match(event.request) but then doesn't actually used the cached response, which is likely not what you intend.
That said, let's walk through what I think is your desired logic:
Your service worker attempts to make a request against the network.
If that request returns a valid response, use it, and you'd done.
If that request fails, then:
If it was a navigation request, regardless of the destination URL, use the cached offline.html for the response.
Otherwise, for non-navigation requests (like CSS or JS requests), use the cached entry matching the desired URL for the response.
Here's a service worker that implements that. You'll need to ensure that the CSS, JS, and offline.html assets are cached during service worker installation; this just includes the fetch handler logic.
self.addEventListener('install', (event) => {
event.waitUntil(
/* Cache your offline.html and the CSS and JS it uses here. */
);
});
async function fetchLogic(request) {
try {
// If the network request succeeds, just use
// that as the response.
return await fetch(request);
} catch(error) {
// Otherwise, implement fallback logic.
if (request.mode === 'navigate') {
// Use the cached fallback.html for failed navigations.
return await caches.match('offline.html');
}
// Otherwise, return a cached copy of the actual
// subresource that was requested.
// If there's a cache miss for that given URL, you'll
// end up with a NetworkError, just like you would if
// there were no service worker involvement.
return await caches.match(request.url);
}
}
self.addEventListener('fetch', (event) => {
event.respondWith(fetchLogic(event.request));
});
There's also some formal guidance in this article.

How to update the cached files in my service worker every 30 minutes?

I have this service worker:
//IMPORT POLYFILL
importScripts('cache-polyfill.js');
//INSTALL
self.addEventListener('install', function(e) {
e.waitUntil(
caches.open('stock_item_balance_v1').then(function(cache) {
return cache.addAll([
'/app/offline_content/purchase/stock_items/stock_items_balance.php',
'/app/offline_content/purchase/stock_items/js/stock_items_balance.js'
]);
})
);
});
//FETCH (FETCH IS WHEN YOU CHECK FOR INTERNET)
self.addEventListener('fetch', function(event) {
//console.log(event.request.url);
event.respondWith(
caches.match(event.request).then(function(response) {
return response || fetch(event.request);
})
);
});
In "stock_items_balance.php" i fetch data from my DB. So in every 30 minutes i would like to update my cached pages and reload the window.
So first i have a script that checks for internet connection.
If true, i want to clean/update the cache and reload the page.
How can i do that?
//INTERVAL
setInterval(function(){
//CLEAN/UPDATE CACHED FILES
serviceworker.update(); // ???
//RELOAD PAGE
window.location.reload();
}, 180000);
(I think you have a larger question as to whether the approach you describe is actually going to give a good, predictable, offline-capable experience for your users, but I'm just going to focus on the actual technical question you asked.)
Messaging the service worker
First off, you should keep in mind that it's possible to have multiple tabs open for the same URL, and if you, you're going to end up with your update code potentially running multiple times. The code in this answer handles the "reload" step for you from inside of the service worker, after the asynchronous cache update has completed, by getting a list of all the active clients of the service worker and telling each to navigate to the current URL (which is effectively a reload).
// Additions to your service worker code:
self.addEventListener('message', (event) => {
// Optional: if you need to potentially send different
// messages, use a different identifier for each.
if (event.data === 'update') {
event.waitUntil((async () => {
// TODO: Move these URLs and cache names into constants.
const cache = await caches.open('stock_item_balance_v1');
await cache.addAll([
'/app/offline_content/purchase/stock_items/stock_items_balance.php',
'/app/offline_content/purchase/stock_items/js/stock_items_balance.js'
]);
const windowClients = await clients.matchAll();
for (const windowClient of windowClients) {
// Optional: check windowClient.url first and
// only call navigate() if it's the URL for one
// specific page.
windowClient.navigate(windowClient.url);
}
})());
}
});
// Additions to your window/page code:
setInterval(() => {
if (navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.postMessage('update');
}
}, 180000);
What won't work
The Cache Storage API is available from both inside a service worker and inside of your page's window scope. Normally what I'd recommend that folks do is to open up the same cache from the window context, and call cache.add() to update the cached entry with the latest from the network. However, calling cache.add() from the window context will cause the network request to be intercepted by your fetch handler, and at that point, your response won't actually come from the network. By calling cache.add() from inside your service worker, you can guarantee that the resulting network request won't trigger your fetch handler.

Show service worker update notification even after page reload

I'm using this well-known pattern for showing a notification when a service worker update is ready to install (this code goes into the web page, it's NOT the service worker code, of course):
// Register service worker.
let newWorker;
if ('serviceWorker' in navigator) {
function showUpdateNotification () {
document.getElementById('updatenotification').style['visibility'] = 'visible';
};
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').then(registration => {
console.log('Service worker registered at scope "' + registration.scope + '".');
// The commented code below is needed to show the notification after a page reload.
//
// if (registration.waiting) {
// console.log('Service working in skipwaiting state.');
// showUpdateNotification();
// }
registration.onupdatefound = () => {
console.log('Service worker update found.');
console.log('Installing service worker is', registration.installing);
newWorker = registration.installing;
newWorker.onstatechange = function () {
console.log('Service worker state changed to', newWorker.state);
if (newWorker.state == 'installed' && navigator.serviceWorker.controller) {
console.log('New service worker is ready to install on refresh.');
showUpdateNotification();
}
};
};
console.log('Updating service worker.');
registration.update();
}).catch(error => console.log('Service worker not registered (' + error +').'))
})
}
Of course, that code works, in the sense that it shows a notification on the web page if a new version of the service worker is ready to be installed.
The problem is that if the page is reloaded at that point, the notification is no longer shown, because if the new service worker is installed and waiting to activate, the updatefound event is no longer fired.
So, the notification only appears ONCE, when the new service worker is installed and waiting to be activated and start controlling the page, but once the page reloads, the notification is gone.
I've solved that by using the commented code:
// The commented code below is needed to show the notification after a page reload.
//
// if (registration.waiting) {
// console.log('Service working in skipwaiting state.');
// showUpdateNotification();
// }
This code, upon registration, checks if there's some service worker in waiting state and shows, again, the notification.
My question is: is this correct? Can I use that "trick" safely or am I calling trouble?
I'm new to service workers so I'm not sure if I can do this kind of things.
Thanks A LOT in advance.
Well, sorry for the self-reply, but more or less I got what I needed, while at the same time handling all the cases (at least, all the cases I need for my project).
I think the code below more or less covers the handling of the entire life-cycle of a service worker, and can be used as a non-very sophisticated boilerplate code for that.
I designed this code using information from a myriad of sources, including StackOverflow, blogs, code from other PWAs, etc. Unfortunately I didn't wrote down each and every source of information, so I'm very sorry for that and I wanted to make clear that I wrote the code below but I didn't invent it, I used information and wisdom from others.
Thanks a lot!
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
let refreshing = false;
navigator.serviceWorker.addEventListener('controllerchange', () => {
console.log('New service worker in charge.');
if (refreshing) return;
refreshing = true;
window.location.reload();
});
navigator.serviceWorker.register('/sw.js').then(registration => {
console.log('Service worker registered.');
// No controller for this page, nothing to do for now.
if (!navigator.serviceWorker.controller) {
console.log('No service worker controlling this page.');
}
// A new service worker has been fetched, watch for state changes.
//
// This event is fired EVERY TIME a service worker is fetched and
// succesfully parsed and goes into 'installing' state. This
// happens, too, the very first time the page is visited, the very
// first time a service worker is fetched for this page, when the
// page doesn't have a controller, but in that case there's no new
// version available and the notification must not appear.
//
// So, if the page doesn't have a controller, no notification shown.
registration.addEventListener('updatefound', function () {
console.log('New service worker in installing state.');
registration.installing.onstatechange = function () {
console.log('Service worker state changed to', registration.state);
if (registration.state == 'installed') {
if (!navigator.serviceWorker.controller) {
console.log('First install for this service worker.');
} else {
console.log('New service worker is ready to install on refresh.');
}
}
};
});
// If a service worker is in 'waiting' state, then maybe the user
// dismissed the notification when the service worker was in the
// 'installing' state or maybe the 'updatefound' event was fired
// before it could be listened, or something like that. Anyway, in
// that case the notification has to be shown again.
//
if (registration.waiting) {
console.log('Service working in skipwaiting state.');
}
// Well, really this should go into a setInterval() call, but I'm
// including it here to be exhaustive.
console.log('Updating service worker.');
registration.update();
}).catch(error => console.log('Service worker not registered (' + error +').'))
})
} else {
console.log('Service workers not supported.');
}

How do I load a service worker before all other requests?

I'm trying to load a service worker before all subresource requests on the page so I can apply some optimizations to the way subresources are loaded (e.g. lazy-loading, loading minified versions of assets instead of full assets). However, I cannot find a way load my SW before other subresource requests begin.
I created a simple proof-of-concept of what I'm trying to do to 401 any requests handled by my Service Worker (just to make it easier to find when my SW begins handling requests).
Here's my HTML:
<!doctype html>
<head>
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/dl-wps-sw.js', { scope: '/' }).then(function (registration) {
console.log('Service Worker registration successful with scope: ', registration.scope);
}, function (err) {
console.error(err);
});
}
</script>
...
and here's my Service Worker:
self.addEventListener('install', function (event) {
self.skipWaiting();
});
self.addEventListener('activate', () => {
self.clients.claim();
});
self.addEventListener('fetch', (event) => {
const init = {status: 401, statusText: 'Blocked!'};
event.respondWith(new Response(null, init));
});
This is what happens in my browser:
As you can see in the screenshot, even though my code to register the Service Worker is at the very top of the page, it doesn't activate and begin handling requests until a bit later, and by then a large number of critical requests I need to catch have already fired.
I found someone else who seemed to be trying to do what I'm trying to accomplish (2 years earlier) in a Github issue for the Service Worker spec: https://github.com/w3c/ServiceWorker/issues/1282
It seems they suggested using a <link rel="serviceworker"... tag to do this, but it appears this link type has since been removed from Chrome for some reason: https://www.chromestatus.com/feature/5682681044008960
I've tried several other ideas to attempt to load my SW first:
Preloading my SW with a <link rel="preload"... tag
Preloading my SW with a fetch/XMLHttpRequest
Inlining the Service Worker in the HTML (not possible apparently)
Delaying the execution of the page by running a while loop for a few seconds (this kinda worked, but its a terrible unpredictable hack)
Any ideas or strategies I'm missing? My Google-fu has failed me on coming up with a solution.
You'll need to install the SW alone first, then refresh the page. The SW can then serve content for a SW-enabled page and intercept all other requests. Example: fetch-progress.anthum.com
index.html
<p>Installing Service Worker, please wait...</p>
<script>
navigator.serviceWorker.register('sw.js')
.then(reg => {
if (reg.installing) {
const sw = reg.installing || reg.waiting;
sw.onstatechange = function() {
if (sw.state === 'installed') {
// SW installed. Refresh page so SW can respond with SW-enabled page.
window.location.reload();
}
};
} else if (reg.active) {
// something's not right or SW is bypassed. previously-installed SW should have redirected this request to different page
handleError(new Error('Service Worker is installed and not redirecting.'))
}
})
.catch(handleError)
function handleError(error) {}
</script>
sw.js
self.addEventListener('fetch', event => {
const url = event.request.url;
const scope = self.registration.scope;
// serve index.html with service-worker-enabled page
if (url === scope || url === scope+'index.html') {
const newUrl = scope+'index-sw-enabled.html';
event.respondWith(fetch(newUrl))
} else {
// process other files here
}
});
index-sw-enabled.html
<!--
This page shows after SW installs.
Put your main app content on this page.
-->

Service worker offline support with pushstate and client side routing

I'm using a service worker to introduce offline functionality for my single page web app. It's pretty straightforward - use the network when available, or try and fetch from the cache if not:
service-worker.js:
self.addEventListener("fetch", event => {
if(event.request.method !== "GET") {
return;
}
event.respondWith(
fetch(event.request)
.then(networkResponse => {
var responseClone = networkResponse.clone();
if (networkResponse.status == 200) {
caches.open("mycache").then(cache => cache.put(event.request, responseClone));
}
return networkResponse;
})
.catch(_ => {
return caches.match(event.request);
})
)
})
So it intercepts all GET requests and caches them for future use, including the initial page load.
Switching to "offline" in DevTools and refreshing at the root of the application works as expected.
However, my app uses HTML5 pushstate and a client side router. The user could navigate to a new route, then go offline, then hit refresh, and will get a "no internet" message, because the service worker was never told about this new URL.
I can't think of a way around it. As with most SPAs, my server is configured to serve the index.html for a number of catch-all URLs. I need some sort of similar behaviour for the service worker.
Inside your fetch handler, you need to check whether event.request.mode is set to 'navigate'. If so, it's a navigation, and instead of responding with a cached response that matches the specific URL, you can respond with a cached response for your index.html. (Or app-shell.html, or whatever URL you use for the generic HTML for your SPA.)
Your updated fetch handler would look roughly like:
self.addEventListener('fetch', event => {
if (event.request.method !== 'GET') {
return;
}
if (event.request.mode === 'navigate') {
event.respondWith(caches.match('index.html'));
return;
}
// The rest of your fetch handler logic goes here.
});
This is a common use case for service workers, and if you'd prefer to use a pre-packaged solution, the NavigationRoute class in the workbox-routing module can automate it for you.

Categories