I have made a Progressive Web App (PWA) that runs on Android and iOS and needs to function without any internet connection. Obviously this is one of the key benefits of a PWA using a service worker, but I have run into a little problem with Apple devices.
When a new user first "installs" the app on an iPhone or iPad it doesn't immediately work offline. The app has to be opened first with an internet connection and thereafter it does work offline. Presumably this is because the service worker doesn't create the cache at the time of "install" but only when the app is opened.
The service worker (more or less copied from online sources) is
const cacheName = 'v0.54';
const contentToCache = [
'index.html',
'before.html',
'scoring.html',
'check.html',
'correction.html',
'after.html',
'help.html',
'register.js',
'manifest.json',
'css/style.min.css',
'css/ace-responsive-menu.min.css',
'/js/ace-responsive-menu.min.js',
'/js/jquery.js',
'/fonts/Merriweather-Regular.woff',
'/fonts/Merriweather-Bold.woff',
'/images/bike.png',
'/images/littlehotels.png',
'/images/share.svg',
'/fav/apple-touch-icon.png',
'/fav/browserconfig.xml',
'/fav/favicon-32x32.png',
'/fav/favicon-16x16.png',
'/fav/favicon.ico',
'/fav/safari-pinned-tab.svg',
'/fav/maskable_icon.png',
'/fav/android-chrome-192x192.png',
'/fav/android-chrome-512x512.png'
];
/* Start the service worker and cache all of the app's content */
self.addEventListener("install", (e) => {
console.log("[Service Worker] Install");
e.waitUntil(
(async () => {
const cache = await caches.open(cacheName);
console.log("[Service Worker] Caching all: app shell and content");
await cache.addAll(contentToCache);
})()
);
});
/* Clear any old caches */
self.addEventListener("activate", (e) => {
e.waitUntil(
caches.keys().then((keyList) => {
return Promise.all(
keyList.map((key) => {
if (key === cacheName) {
return;
}
return caches.delete(key);
})
);
})
);
});
/* Serve cached content when offline */
self.addEventListener("fetch", (e) => {
e.respondWith(
(async () => {
const r = await caches.match(e.request);
console.log(`[Service Worker] Fetching resource: ${e.request.url}`);
if (r) {
return r;
}
const response = await fetch(e.request);
const cache = await caches.open(cacheName);
console.log(`[Service Worker] Caching new resource: ${e.request.url}`);
cache.put(e.request, response.clone());
return response;
})()
);
});
I know that Apple support for PWAs falls a long way short of Google, so I am guessing that iOS is not executing the self.addEventListener("install", (e) => {. iOS doesn't really "install" the app but just creates a shortcut on the Home Screen, so is there an alternative event listener that would create the cache at the time of creating the shortcut?
Related
recently I started learning about service workers, background syncs... I implemented service worker and in install step I cached some files I want to show when offline.
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE)
.then((cache) => {
return cache.addAll([navigationIcon, offlineImage, offlineFallbackPage]);
})
);
});
I am listening to fetch event to catch when there is no internet connection so I can show offline page when then.
self.addEventListener('fetch', (event) => {
if (event.request.mode === 'navigate' || (event.request.method === 'GET'
&& event.request.headers.get('accept')
.includes('text/html'))) {
event.respondWith(
fetch(event.request.url)
.catch(() => {
// Return the offline page
return caches.match(offlineFallbackPage);
})
);
} else {
event.respondWith(caches.match(event.request)
.then((response) => {
return response || fetch(event.request);
}));
}
});
I also added background sync, so I can go back online when there is internet connection.
After registering service worker I added:
.then(swRegistration => swRegistration.sync.register('backOnline'))
And I listen to sync event in my service worker.
When I'm offline and go back online nothing happens. BUT when I delete my fetch event (don't show previously cached offline page) then page goes back online by itself (which I want to do when I have fetch event)
Does anyone know what should I add so my page can go back online by itself?
You can use navigator, Include it in your main js file that is cached or in your service-worker js file, just ensure it's cached
let onlineStatus = locate => {
if(navigator.onLine){
location.replace(locate)
}
}
let isOfflinePage = window.location.pathname == offlineFallbackPage ? true : false;
// kindly edit isOfflinePage to return true if it's offline page
if(isOfflinePage){
onlineStatus('index.html')
}
You can simply use location.reload() instead
The user story:
On a browser, I have my Progressive Web App opened on any URL
https://mypwa.com/xxxx
I receive a mail, with a link to a page of my PWA
https://mypwa.com/post/<postId>
Instead of opening a new tab in the browser, I want my existing PWA tab to get the focus and go on the URL. I don't want a new tab nor a new PWA startup (it costs a lot in records download)
With postMessage, Service Worker can tell existing tab to get focus and go on the /post/<postId> path. Supposed to work but I get a "DOM Exception" on client.focus() in the service worker, from a fetch event, in short:
self.addEventListener("fetch", function(event) {
var requestUrl = new URL(event.request.url);
var pathname = requestUrl.pathname;
if (
event.request.mode === "navigate" &&
requestUrl.origin === location.origin
) {
const rootUrl = new URL("/", location).href;
const mg = pathname.match(regexpPost);
const postId = mg[1];
event.waitUntil(
clients.matchAll().then(matchedClients => {
for (let client of matchedClients) {
if (client.url.indexOf(rootUrl) >= 0) {
return client
.focus() // ***** CRASHING HERE *****
.then(() => {
return sendMessageClient(client, { postId }).then(resp => {
other;
});
});
}
}
})
);
}
});
What I'm missing also is how to close the newly opened tab. I don't want to keep it: dead tabs can accumulate and this is not a good user experience.
Tried javascript window.close(); but it has to respond to a user action...
I have a service worker for caching images, this service worker is only registered within the frontend template but it still keeps spreading into my admin template.
This causes my forms to behave unpredictably as the validation tokens get impacted with it.
With some console.log I figured the install event is triggered before getting to the requested page but I'm unable to determine the current/next URL there.
How can I prevent the service worker to spreading to the admin panel and interfere with the pages? I just want only assets to be cached.
This is my service worker as far as that is relevant:
const PRECACHE = 'precache-v1.0.0';
const RUNTIME = 'runtime';
// A list of local resources we always want to be cached.
const PRECACHE_URLS = [
"public",
"media",
"unify",
];
importScripts('./cache-polyfill.js');
// The install handler takes care of precaching the resources we always need.
self.addEventListener('install', function(event) {
console.log('installing resources');
event.waitUntil(
caches.open(PRECACHE)
//.then(cache => cache.addAll(PRECACHE_URLS))
.then(self.skipWaiting())
);
});
// The activate handler takes care of cleaning up old caches.
self.addEventListener('activate', function(event) {
const currentCaches = [PRECACHE, RUNTIME];
event.waitUntil(
caches.keys().then(cacheNames => {
return cacheNames.filter(cacheName => !currentCaches.includes(cacheName));
}).then(cachesToDelete => {
return Promise.all(cachesToDelete.map(cacheToDelete => {
return caches.delete(cacheToDelete);
}));
}).then(() => self.clients.claim())
);
});
// The fetch handler serves responses for same-origin resources from a cache.
// If no response is found, it populates the runtime cache with the response
// from the network before returning it to the page.
self.addEventListener('fetch', event => {
// Skip cross-origin requests, like those for Google Analytics.
if (event.request.method === "GET") {
if (event.request.url.indexOf(PRECACHE_URLS) > -1) {
console.log("fetching " + event.request.url + " by the service worker");
event.respondWith(
caches.match(event.request).then(cachedResponse => {
if (cachedResponse) {
return cachedResponse;
}
return caches.open(RUNTIME).then(cache => {
return fetch(event.request).then(response => {
// Put a copy of the response in the runtime cache.
return cache.put(event.request, response.clone()).then(() => {
console.log('cached: ' + event.request.url);
return response;
});
});
});
})
);
}
else {
console.log("fetching " + event.request.url + " by service worker blocked, it's not a resource");
}
}
return fetch(event.request);
});
The problem is most likely that your admin pages lie inside the SW scope. This means that your SW controls eg. everything in / and your admin pages are located in /admin/ or something.
You can prevent the behaviour by checking the fetch requests your SW is intercepting. Something like:
if (event.request.url.match('^.*(\/admin\/).*$')) {
return false;
}
This should be the first thing in the SW's fetch listener. It checks whether it received a request for something from the admin pages and then cancels out if it did. Otherwise, it continues normally.
I was watching Steve Sanderson's NDC presentation on up-and-coming web features, and saw his caching example as a prime candidate for an application I am developing. I couldn't find the code, so I have typed it up off the Youtube video as well as I could.
Unfortunately it doesn't work in Chrome (which is also what he is using in the demo) It fails with Uncaught TypeError: fetch(...).then(...).timeout is not a function
at self.addEventListener.event.
I trawled through Steve's Github, and found no trace of this, nor could I find anything on the NDC Conference page
//inspiration:
// https://www.youtube.com/watch?v=MiLAE6HMr10
//self.importScripts('scripts/util.js');
console.log('Service Worker script running');
self.addEventListener('install', event => {
console.log('WORKER: installing');
const urlsToCache = ['/ServiceWorkerExperiment/', '/ServiceWorkerExperiment/scripts/page.js'];
caches.delete('mycache');
event.waitUntil(
caches.open('mycache')
.then(cache => cache.addAll(urlsToCache))
.then(_ => self.skipWaiting())
);
});
self.addEventListener('fetch', event => {
console.log(`WORKER: Intercepted request for ${event.request.url}`);
if (event.request.method !== 'GET') {
return;
}
event.respondWith(
fetch(event.request)
.then(networkResponse => {
console.log(`WORKER: Updating cached data for ${event.request.url}`);
var responseClone = networkResponse.clone();
caches.open('mycache').then(cache => cache.put(event.request, responseClone));
return networkResponse;
})
//if network fails or is too slow, return cached data
//reference for this code: https://youtu.be/MiLAE6HMr10?t=1003
.timeout(200)
.catch(_ => {
console.log(`WORKER: Serving ${event.request.url} from CACHE`);
return caches.match(event.request);
})
);
});
As far as I read the fetch() documentation, there is no timeout function, so my assumption is that the timeout function is added in the util.js which is never shown in the presentation... can anyone confirm this? and does anyone have an Idea about how this is implemented?
Future:
It's coming.
According to Jake Archibald's comment on whatwg/fetch the future syntax will be:
Using the abort syntax, you'll be able to do:
const controller = new AbortController();
const signal = controller.signal;
const fetchPromise = fetch(url, {signal});
// 5 second timeout:
const timeoutId = setTimeout(() => controller.abort(), 5000);
const response = await fetchPromise;
// …
If you only wanted to timeout the request, not the response, add:
clearTimeout(timeoutId);
// …
And from another comment:
Edge & Firefox are already implementing. Chrome will start shortly.
Now:
If you want to try the solution that works now, the most sensible way is to use this module.
It allows you to use syntax like:
return fetch('/path', {timeout: 500}).then(function() {
// successful fetch
}).catch(function(error) {
// network request failed / timeout
})
I am installing a service worker for the first time, and following the tutorial at: https://developers.google.com/web/fundamentals/getting-started/primers/service-workers
My service worker behaves as expected when installing and updating, but fetch requests are not triggered as expected.
var CACHE_NAME = 'test-cache-v1'
var urlsToCache = [
'/',
'/public/scripts/app.js'
]
self.addEventListener('install', function (event) {
console.log('Installing new service worker', event)
// Perform install steps
event.waitUntil(
caches.open(CACHE_NAME)
.then(function (cache) {
return cache.addAll(urlsToCache)
})
.catch(err => console.log('Error Caching', err))
)
})
self.addEventListener('fetch', function (event) {
console.log('Fetch req', event)
event.respondWith(
caches.match(event.request)
.then(function (response) {
console.log('Cache hit', response)
// Cache hit - return response
if (response) {
return response
}
return fetch(event.request)
.catch(e => console.log('Error matching cache', e))
}
)
)
})
I see 'Installing new service worker' outputted to the console when expected, but not 'Fetch req'. I am using Chrome devtools and have accessed the "Inspect" option next to the ServiceWorker under the Application tab.
If you listen for the activate event, and add in a call to clients.claim() inside that event, then your newly active service worker will take control over existing web pages in its scope, including the page that registered it. There's more information in this article on the service worker lifecycle. The following code is sufficient:
self.addEventListener('activate', () => self.clients.claim());
If you don't call clients.claim(), then the service worker will activate, but not control any of the currently open pages. It won't be until you navigate to the next page under its scope (or reload a current page) that the service worker will take control, and start intercepting network requests via its fetch handler.
On dynamic websites, be careful!
If service worker has scope: example.com/weather/
It does not have scope: example.com/weather
Especially on firebase which by default removes trailing slash
In this case, service worker will install, activate, and even cache files, but not receive ‘fetch’ events! Very hard to debug.
Add “trailingSlash”: true to firebase.json under ‘hosting’. This will solve the problem. Make sure to modify rewrite from:
{
"source": "/weather", "function": "weather"
}
To :
{
"source": "/weather/", "function": "weather"
}
As well as manifest.json
I found that Jeff Posnick's "clients.claim()" in the activate event handler was useful, but it was not enough to cache resources the first time the JS app runs. That is because on the first run the service worker has not finished activating when the JS starts loading its resources.
The following function lets the main app register the SW and then waits for it to activate before continuing to load resources:
/**
* Registers service worker and waits until it is activated or failed.
* #param js URI of service worker JS
* #param onReady function to call when service worker is activated or failed
* #param maxWait maximum time to wait in milliseconds
*/
function registerServiceWorkerAndWaitForActivated(js, onReady, maxWait) {
let bReady = false;
function setReady() {
if (!bReady) {
bReady = true;
onReady();
}
}
if ('serviceWorker' in navigator) {
setTimeout(setReady, maxWait || 1000);
navigator.serviceWorker.register(js).then((reg) => {
let serviceWorker = reg.installing || reg.waiting;
if (serviceWorker) {
serviceWorker.addEventListener("statechange", (e) => {
if (serviceWorker.state == "activated")
setReady();
});
} else {
if (!reg.active)
console.log("Unknown service worker state");
setReady();
}
}, () => setReady());
} else {
let msg = "ServiceWorker not available. App will not run offline."
if (document.location.protocol != "https:")
msg = "Please use HTTPS so app can run offline later.";
console.warn(msg);
alert(msg);
setReady();
}
}