Подводные камни 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);
        }));
    });
    

    P.S. Автор не претендует на оригинальность, однако считает, что если данная статья будет найдена, прочитана и поможет хоть одному несчастному — оно того стоит.
    • +17
    • 10,2k
    • 7
    Поделиться публикацией

    Комментарии 7

      0
      а для чего переписывать урл к картинкам? если картинка уже есть у пользователя, все равно вернется 304. Чаще всего браузер вообще не пойдет в сеть и покажет картинку из кеша, не проверяя есть ли изменения
        0
        это была часть задачи. там суть проекта была в том, чтобы упаковать существующее веб-приложение в десктопный апп, способный работать в оффлайне. долгая история, но вкратце — в офисе скачиваются необходимые данные, потом человек едет куда-то и проводит презентацию используя предварительно сохраненные документы/картинки и т.д.
        0
        Я думаю это было сделано как пример.
          +1
          Кратко, но полезно!
            0
            Спасибо за отзыв!
            0
            Спасибо за статью, полезно :)

            Я правильно понял что воркер не будет работать со страницей, которая была запущена впервые на девайсе? Заметил такую штуку, если удалить serviceworker и открыть страницу, воркер не получает событие fetch. Если после этого обновить страницу — событие fetch воркеру прилетает. Это нормальное поведение? Если да, то как сделать так чтобы воркер начал кешировать ресурсы при первом заходе на страницу? возможно ли это?

            Спасибо.
              0
              между регистрацией/загрузкой воркера, установкой, активацией и началом получения событий от страницы есть небольшие задержки по времени. в SW все происходит не мгновенно и асинхронно, соответственно если страница загружается впервые это и происходит.
              вот тут есть инфографика цикла жизни воркера mdn.mozillademos.org/files/12636/sw-lifecycle.png
              слушайте события install и activate в воркере чтобы увидеть когда у Вас в действительности происходит установка.
              если Вам важно чтобы все запросы от страницы проходили через фетч воркера, то имеет смысл в обработчике onActivate отправить message подключенным клиентам (странице) и на стороне страницы начинать работу уже когда получили это сообщение.
              self.send_message_to_client = function(client, msg){
                return new Promise(function(resolve, reject){
                  var msg_chan = new MessageChannel();
                  msg_chan.port1.onmessage = function(event){
                    if(event.data.error){
                      reject(event.data.error);
                    } else {
                      resolve(event.data);
                    }
                  };
                  client.postMessage("SW Says: '"+msg+"'", [msg_chan.port2]);
                });
              };
              
              self.send_message_to_all_clients = function (msg) {
                clients.matchAll().then(clients => {
                    clients.forEach(client => {
                    self.send_message_to_client(client, msg).then(m => console.log("SW Received Message: " + m));
                  });
                });
              };
              
              self.addEventListener("activate", function (event) {
                console.info('service worker activated');
              
                event.waitUntil(
                  clients.claim().then(function () {
                    // console.info('clients.claim().then here');
                    self.send_message_to_all_clients("do it now");
                  })
                )
              });
              

            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

            Самое читаемое