Подводные камни Service Workers

В этом коротеньком очерке я опишу те вещи о service workers, о которых я бы хотел прочесть год или хотя бы полгода назад и тем самым избежать очень долгого и мучительного периода отладки приложения.

Для тех, кто вообще не в курсе о чем речь, то очень вкратце — service worker это скрипт, который выполняется браузером в фоне, отдельно от веб-страницы и способен выполнять функции для которых не требуется взаимодействие со страницей или пользователем. 


Полную документацию найти не сложно, но вот ссылка.

Еще мне очень пригодился вот этот материал и мне очень жаль что я невнимательно с ним ознакомился когда только начал знакомство с ServiceWorker API.

На практике Service Worker API позволяет делать такую магическую вещь, как кеширование файлов онлайн веб-приложения на локальное устройство пользователя и затем работать полностью в оффлайне, если нужно.

В будущем планируется добавить такие классные вещи как синхронизация кеша в фоне, то есть даже если пользователь не находится сейчас на вашем сайте, сервис-воркер все равно сможет запуститься и скачать обновления например. А также доступ к PushApi из фона опять же (то есть при получении обновления отправить вам пуш-уведомление).

Как это работает.

  • Веб-страница регистрирует service worker
  • Service worker устанавливается, активируется и начинает в фоне что-то делать. Например “слушать” события ‘fetch’ и при необходимости их изменять или отменять совсем

Используя Channel Messaging API веб-страница может отправлять сообщения service worker и получать от него ответы (и наоборот).
Service Worker НЕ может иметь доступа к DOM и данным веб-страницы, кроме как посредством сообщений.

Чего я не знал до недавнего времени, так это того, что даже если пользователь сейчас на вашем сайте, это никак не гарантирует того, что service worker будет работать все время. И ни в коем случае нельзя полагаться на global scope воркера. 

То есть вот такая схема работы привела меня к очень плачевным последствиям и длительному процессу отладки приложения (внимание, плохой код, НЕ использовать ни в коем случае):

регистрация/установка service worker
Index.html

var regSW = require("./register-worker.js");
var sharedData = {filesDir: localDir};
regSW.registerServiceWorker(sharedData);

register-worker.js 

var registerServiceWorker = function(sharedData){
  navigator.serviceWorker.register('service-worker.js', { scope: './' })
    .then(navigator.serviceWorker.ready)
    .then(function () {
      console.log('service worker registered');
      sendMessageToServiceWorker(sharedData).then(function(data) {
        console.log('service worker respond with message:', data);
      })
      .catch(function(error) {
        console.error('send message fails with error:', error);
      });
    })
    .catch(function (error) {
      console.error('error when registering service worker', error, arguments)
    });
};
var sendMessageToServiceWorker = function(data){
  return new Promise(function(resolve, reject) {
    var messageChannel = new MessageChannel();
    messageChannel.port1.onmessage = function(event) {
      if (event.data.error) {
        reject(event.data.error);
      } else {
        resolve(event.data);
      }
    };
    navigator.serviceWorker.controller.postMessage(data,
      [messageChannel.port2]);
  });
};

Код воркера, с прослушкой fetch и подменой ответа
service-worker.js

self.addEventListener('message', function(event) {
  self.filesDir = event.data.filesDir;
  event.ports[0].postMessage({'received': event.data});
});
self.addEventListener('fetch', function fetcher(event) {
  let url = event.request.url;
  if (url.indexOf("s3") > -1) {
    //redirect to local stored file
    url = "file://" + self.filesDir + self.getPath(url);
    let responseInit = {
      status: 302,
      statusText: 'Found',
      headers: {
        Location: url
      }
    };
    let redirectResponse = new Response('', responseInit);
    event.respondWith(redirectResponse);
  }
});

Что тут произошло:

  • Мы зарегистрировали воркер и отправили ему в сообщении по какому локальному пути нужно искать закешированные ранее файлы.
  • В сервис воркере мы получили сообщение и сохранили путь в global scope воркера в переменную self.filesDir.
  • Воркер слушает событие fetch и на все что содержит в пути “s3” отвечает редиректом на локальный файл.

Оговорюсь что код сильно упрощен (конечно я не подменяю все что содержит s3 в пути, я не настолько ленив), но главное он показывает.

И все бы ничего, если бы не факт, что по истечению случайного количества времени (3-10 минут) работы приложения, service worker начинал перенаправлять запросы “в никуда”, а точнее в что-то типа «file://undefined/images/image1.png»
То есть спустя какое-то время переменная self.filesDir попросту удаляется и мы получаем тонну 404 file not found вместо картинок.

Естественно ни один уважающий себя программист не будет тестировать приложение целых 5 минут. Поэтому баг обнаруживает в лучшем случае тестер. А обычно даже клиент. Потому что сами знаете, эти тестеры… И вообще, тестирование никто не оплачивал, скажите спасибо что при старте не крашится.

В общем чтобы долго не затягивать, проблема в том, что если service worker не используется [какое-то время], то браузер его прибивает (извините не придумал более уместного перевода для слова terminate) и затем при следующем обращении стартует снова. Соответственно, новая копия воркера знать не знает о чем там его мертвый предшественник общался с веб-страницей и у него нет никаких сведений о том, откуда брать файлы. 

Поэтому, если Вам нужно что-то сохранить — делайте это в перманентном хранилище, а именно в IndexedDB. 

Еще одна заметка — говорят воркер не может использовать синхронные API, поэтому localStorage API не удастся использовать. Но я лично не пробовал.

Кстати, отладка в моем случае затянулась еще и потому что даже когда я тестировал долго-долго (минут 7) в надежде воспроизвести все-таки баг, у меня не получалось, так как при открытом окне Developer Tools коварный chrome не убивает воркер. Хоть и сообщает об этом лаконичным сообщением в логах “Service Worker termination by a timeout was canceled because DevTools is attached”

Собственно тут до меня и дошло, почему мои многократные попытки выяснить почему ServiceWorker у меня работает иначе чем у клиента на production провалились…

image

В общем после того как я убрал установку пути в переменной и перенес это в indexedDB мои несчастья закончились и мне снова начал нравиться ServiceWorker API.

А вот собственно рабочий пример кода, который можно взять и использовать в отличие от предыдущего:

регистрация/установка service worker
index.html

var regSW = require("./register-worker.js");
idxDB.setObject('filesDir', filesDir);
regSW.registerServiceWorker();

register-worker.js 

var registerServiceWorker = function(){
  navigator.serviceWorker.register('service-worker.js', { scope: './' })
    .then(navigator.serviceWorker.ready)
    .then(function () {
      console.log('service worker registered');
    })
    .catch(function (error) {
      console.error('error when registering service worker', error, arguments)
    });
};

Код воркера, с прослушкой fetch и подменой ответа
service-worker.js

self.getLocalDir = function() {
  let DB_NAME = 'localCache';
  let DB_VERSION = 1;
  let STORE = 'cache';
  let KEY_NAME = 'filesDir';
  return new Promise(function(resolve, reject) {
    var open = indexedDB.open(DB_NAME, DB_VERSION);
    open.onerror = function(event) {
      reject('error while opening indexdb cache');
    };
    open.onsuccess = function(event) {
      let db = event.target.result, result;
      result = db.transaction([STORE])
        .objectStore(STORE)
        .get(KEY_NAME);
      result.onsuccess = function(event) {
        if (!event.target.result) {
          reject('filesDir not set');
        } else {
          resolve(JSON.parse(event.target.result.value));
        }
      };
      result.onerror = function(event) {
        reject('error while getting playthroughDir');
      };
    }
  });
};
self.addEventListener('fetch', function fetcher(event) {
  let url = event.request.url;
  if (url.indexOf("s3") > -1) {
    //redirect to local stored file
    event.respondWith(getLocalDir().then(function(filesDir){
        url = "file://" + filesDir + self.getPath(url);
        var responseInit = {
          status: 302,
          statusText: 'Found',
          headers: {
            Location: url
          }
        };
        return new Response('', responseInit);
    }));
});