Search code examples
javascriptvuejs2service-workerworkbox

How to sync my saved data on apple devices with service worker?


I know that Background Sync API is not supported in the apple ecosystem, so how would you get around it and make a solution that would work in the apple ecosystem and other platforms as well, now i have a solution that uses Background Sync API and for some reason it literally does not do anything on IOS, it just saves the failed requests, and then never sync-s, could i just access the sync queue somehow, with a indexedDB wrapper and then sync at an arbitrary time? I tried it once and it broke everything, do you guys have an idea how?

    const bgSyncPlugin = new workbox.backgroundSync.Plugin('uploadQueue', {
    maxRetentionTime: 60 * 24 * 60,
    onSync: async ({ queue }) => {
        return getAccessToken().then((token) => {
            replayQueue(queue, token).then(() => {
                return showNotification();
            });
        });
    },
});

This is the code i have, they all. have a purpose, since my token has a timeout i have to check if the token is expired or not and proceed after that and replace the token in the headers if it is expired, and i have to change data as well when i sync in the request bodies, but it all works good on anything other than apple devices. Apple devices never trigger the onsync, i tried to do listen to fetch events and trigger onsync with:

self.registration.sync.register('uploadQueue');

But to no awail, i tried to register sync on servvice worker registration, nothing seems to help. If the sync registration is not viable on ios, then can i access the upload queue table somehow? P.S.: I`m using dexie.js as a indexedDB wrapper, it is a vue.js app, with laravel api, and the sync process is quite complex, but it is working, just have to figure out how to do it on IOS!


Solution

  • I have found an answer to this after like 2 weeks of it being on my mind and on my to do list. Now get some popcorn and strap yourself the heck in, because this is quite a chonker. In my case the sync process was pretty complex as my users could be away from any connection for such a long time that my accessTokens would expire so i had to do a check for the access token expiration as well and reFetch it. Furthermore my users could add new people to the database of people, which all had their on unique server side id-s, so i had to order my requests in a way that the person registrations are sent first then the tasks and campaigns that were completed for them, so i can receive the respective ids from the API.

    Now for the fun part: Firstly you cant use a bgSyncPlugin, because you cant access the replayQueue, you have to use a normal queue, like this:

    var bgSyncQueue = new workbox.backgroundSync.Queue('uploadQueue', {
        maxRetentionTime: 60 * 24 * 60,
        onSync: () => syncData(),
       });
    

    And push the failed requests to the queue inside the fetch listener:

    this.onfetch = (event) => {
        let requestClone = event.request.clone();
        if (requestClone.method === 'POST' && 'condition to match the requests you need to replay') {
            event.respondWith(
                (() => {
                    const promiseChain = fetch(requestClone).catch(() => {
                        return bgSyncQueue.pushRequest(event);
                    });
                    event.waitUntil(promiseChain);
                    return promiseChain;
                })()
            );
        } else {
            event.respondWith(fetch(event.request));
        }
    };
    

    When user has connection we trigger the "syncData()" function, on ios this is a bit complicated(more on this later), on android it happens automatically, as the service worker sees it has connection, now lets just check out what syncData does:

    async function syncData() {
        if (bgSyncQueue) //is there data to sync?
            return getAccessToken() //then get the access token, if expired refresh it
                .then((token) => replayQueue(bgSyncQueue, token).then(() => showNotification({ body: 'Succsesful sync', title: 'Data synced to server' })))
                .catch(() => showNotification({ title: 'Sync unsuccessful', body: 'Please find and area with better coverage' })); //replay the requests and show a notification
        return Promise.resolve('empty');//if no requests to replay return with empty
    }
    

    For the android/desktop side of thing we are finished you can be happy with your modified data being synced, now on iOS we cant just have the users data be uploaded only when they restart the PWA, thats bad user experience, but we are playing with javascript everything is possible in a way or another.

    There is a message event that can be fired every time that the client code sees that it has internet, which looks like this:

    if (this.$online && this.isIOSDevice) {
                        if (window.MessageChannel) {
                            var messageChannel = new MessageChannel();
                            messageChannel.port1.onmessage = (event) => {
                                this.onMessageSuccess(event);
                            };
                        } else {
                            navigator.serviceWorker.onmessage = (event) => {
                                this.onMessageSuccess(event);
                            };
                        }
                        navigator.serviceWorker.ready.then((reg) => {
                            try {
                                reg.active.postMessage(
                                    {
                                        text: 'sync',
                                        port: messageChannel && messageChannel.port2,
                                    },
                                    [messageChannel && messageChannel.port2]
                                );
                            } catch (e) {
                                //firefox support
                                reg.active.postMessage({
                                    text: 'sync',
                                });
                            }
                        });
                    }
    

    this is inside a Vue.js watch function, which watches whether we have connection or not, if we have connection it also checks if this is a device from the apple ecosystem, like so:

    isIosDevice() {
                return !!navigator.platform && /iPad|iPhone|MacIntel|iPod/.test(navigator.platform) && /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
            }
    

    And so it tells the service worker that it has internet and it has to sync, in that case this bit of code gets activated:

    this.onmessage = (event) => {
        if (event.data.text === 'sync') {
            event.waitUntil(
                syncData().then((res) => {
                    if (res !== 'empty') {
                        if (event.source) {
                            event.source.postMessage('doNotification');//this is telling the client code to show a notification (i have a built in notification system into the app, that does not use push notification, just shows a little pill on the bottom of the app with the message)
                        } else if (event.data.port) {
                            event.data.port.postMessage('doNotification'); //same thing
                        }
                        return res;
                    }
                })
            );
        }
    };
    

    Now the most useful part in my opinion, the replay queue function, this guy gets the queue and the token from getAccessToken, and then it does its thing like clockwork:

        const replayQueue = async (queue, token) => {
            let entry;
            while ((entry = await queue.shiftRequest())) {//while we have requests to replay
                let data = await entry.request.clone().json();
                try {
    //replay the person registrations first and store them into indexed db
                    if (isPersonRequest) {
                        //if new person
                        await fetchPerson(entry, data, token);
    //then replay the campaign and task submissions
                    } else if (isTaskOrCampaignRequest) {
                        //if task
                        await fetchCampaigns(entry, data, token);
                    } 
                } catch (error) {
                    showNotification({ title: 'no success', body: 'go for better internet plox' });
                    await queue.unshiftRequest(entry); //put failed request back into queue, and try again later
                }
            }
            return Promise.resolve();
        };
    

    Now this is the big picture as how to use this guy on iOS devices and make Apple mad as heck :) I am open to any questions that are related, in this time i think i have become pretty good with service worker related stuff as this was not the only difficult part of this project but i digress, thats a story for another day.

    (you may see that error handling is not perfect and maybe this thing is not he most secure of them all, but this project has a prettty small amount of users, with a fixed number which know how to use it and what it does, so im not really afraid of security in this case, but you may want to improve on things if you use in in a more serious project)

    Hope i could help and all of you have a grea day.