ONE DUDE`S BLOG

/media/pwa.webp

PWA - progressive web app

28.11.2021
Технология позволяющая писать полноценный мобильные (и не только) приложения в вебе.

Весь код прдставленный ниже лишь для изучения. Он весьма плохого качества, изобилует антипаттернами, повторением, устаревшими стандартами (отсутствием синтаксического сахара). Иными словами, он лишь для изучения PWA и методов для взаимодействия с новыми технологиями. Большинство примеров честным образом стырено с udemy. Все примеры доступны на github

Полезные ссылки

Progressive web app :pwa:

PWA vs native app

Для PWA доступно множество фич для работы с переферией, однако, native приложений ~87% pwa ~13%. Такое соотношение обусловлено относительной новизной технологии, а также неполной поддержкой браузеров.

App manifest

App manifest - корневой файл, описывающий мета информацию о работе нашего PWA.

Файл аходится в корне проекта manifest.json

Манифест
{
  "name": "Имя нашего PWA",
  "short_name": "Короткое имя для иконки приложения",
  "start_url": "Страница загружаемая при открытии PWA, например index.html",
  "scope": "Страницы включаемые в приложение, например .",
  "display": "standalone - без url бара, как приложение выглядит, standalone - default занчение",
  "background_color": "hex значение для отображения цвета при загрузке приложения",
  "theme_color": "hex цвет баров",
  "description": "...",
  "dir": "read direction ltr/rtl",
  "lang": "язык, например: en-US",
  "orientation": "ориентация экрана: portrait-primary",
  "icons": [
    {
      "src": "...",
      "type": "image/png",
      "sizes": "48x48/96x96"
    }
  ],
  "related_applications": [
    {
      "platform": "play",
      "url": "...",
      "id": ",,"
    }
  ]
}

“display” свойство

  • standalone - как обычное приложение
  • fullscreen - без баров
  • minimal-ui
  • browser - очевидно нет?*

Добавляем в index.html

<link rel="manifest" href="/manifest.json">

Browser

Safari

<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="PWAGram">
<link rel="apple-touch-icon" href="/src/images/icons/..." sizes="144x144">

Internet explorer

Фиг знает зачем вообще что-то поддерживать в ie, но на удивление даже в нем есть какие-то надстройки.

Иконка

<meta name="msapplication-TileImage" content="/src/images/icons/some-img.png">

Цвет иконки

<meta name="msapplication-TileColor" content="#fff"> 

Цвет темы

<meta name="theme-color" content="#3f51b5"> 

Service worker

Worker events

Service worker живет бекграунде.

  1. fetch - page related javascript (http requst) axios и похожее не работает :(
  2. push notifications
  3. взаимодействие с уведомлениям
  4. Синхронизация в бекграунде
  5. Service worker lifecycle

Service worker lifecycle

Регистрация сервис воркера происходит 1 раз, и в случае если он изменился

  1. Install event
  2. Activate event

Регистрация сервис воркера + проверка что он доступен

navigator?.serviceWorker.register("/sw.js").then(function () {
  console.log("Service worker registered!");
});

register принимает объект, позволяющий выделить скоуп для страниц на которых он будет применен

navigator?.serviceWorker.register("/sw.js", { scope: '/help/' }).then(function () {
  console.log("Service worker registered!");
});

Событие installed

Тут и далее self - обращение к сервис воркеру

self.addEventListener("install", (event) => {
  console.log("[Service Worker] installed", event);
});

Событие активации

self.addEventListener("activate", (event) => {
  console.log("[Service Worker] activated", event);
  return self.clients.claim();
});

Можно и не возвращать клиентов, однако рекомендуется возвращать т.к. мб ошибки.

События не в lifecycle

fetch

self.addEventListener("fetch", (event) => {
  console.log("[Service Worker] fetched", event);
  event.respondWith(null);
});

fetch - типичный прокси, можно переопределять ответы (увы, не аксиос и XMLHttpRequest, или не увы, пока непонятно)


self.addEventListener("fetch", (event) => {
  console.log("[Service Worker] fetched", event);
  event.respondWith(fetch(event.request));
});

Установка PWA на устройство

  1. Регистрируем специальный ивент, который позволяет перехватить попап для установки

    window.addEventListener("beforeinstallprompt", function (event) {
      console.log("beforeinstalprompt fired");
      event.preventDefault();
      defaultPrompt = event; // default prompt в глобальном скоупе.
      return false;
    });
    
  2. Далее можно обработать defaultPrompt по нужному тригеру


function openCreatePostModal() {
  createPostArea.style.display = "block";
  if (defaultPrompt) {
    defaultPrompt.prompt();
    defaultPrompt.userChoice.then((choiceResult) => {
      console.log(choiceResult);
      if (choiceResult.outcome === "dismissed") {
        console.log("User canceled installation");
      } else {
        console.log("User accepted installation");
      }
      defaultPrompt = null;
    });
  }
}

shareImageButton.addEventListener("click", openCreatePostModal);
  1. Программно удалить все зарегистрированные воркеры

        if ("serviceWorker" in navigator) {
          navigator.serviceWorker.getRegistrations().then((registrations) => {
            registrations.forEach((r) => {
              r.unregister();
            });
          });
        }
    

Как отписать сервис воркер

navigator.serviceWorker.getRegistrations().then(function(registrations) {
    for (let registration of registrations) {
        registration.unregister()
    }})

Cache для работы оффлайн (статика)

Достаточно тривиально. кешируем запросы, перехватываем обращение к статике и применяем результат из кеша

Реализация кеша
self.addEventListener("install", function (event) {
  console.log("[Service Worker] Installing Service Worker ...", event);
  // Дожидаемся асинхронного выполнения кеширования
  event.waitUntil(
    caches.open("static").then(function (cache) {
      console.log("[Servoce Worker] Pre caching");
      cache.add("/src/js/app.js"); //Кеширование
      cache.add("/");
    })
  );
});

self.addEventListener("fetch", function (event) {
  event.respondWith(
    caches.match(event.request).then(function (response) {
      if (!response) {
        return fetch(event.request);
      }
      return response;
    })
  );
});

Что кешировать не нужно?

  • Полифилы, если браузер старый он и в сервис воркеры не умеет, следовательно и смысла от такого кеширования нет.
  • Иконки (pwa). Т.к. они уже внутри нашего воркера.
  • JSON ответ с сервера (кешируем только статику, для ответов с сервера используется другой подход - локальное хранилище + middleware)
  • POST запросы, как и другие запросы на мутацию. (пожалуй это очевидно, их на сервере обычно не кешируют, за исключением каких-то уж совсем кастомных rpc)

Динамическое кеширование

внутри service-worker скрипта

self.addEventListener("fetch", function (event) {
  event.respondWith(
    caches.match(event.request).then(function (response) {
      console.log(event.request);
      if (!response) {
        return fetch(event.request).then((res) => {
        // Динамически кешируем все загруженные ресурсы
          return caches.open("dynamic").then((cache) => {
            cache.put(event.request.url, res.clone());
            return res;
          });
        });
      }
      return response;
    })
  );
});

Версионирование кеша

Для версионирования необходимо очистить предыдущий кеш и создать новый, с новым именем

caches.open("static-v2").then(function (cache) { ... }

Удаление происходит в хуке активации

self.addEventListener("activate", function (event) {
  console.log("[Service Worker] Activating Service Worker ....", event);
  event.waitUntil(
    caches.keys().then((keyList) => {
      return Promise.all(
        keyList.map((key) => {
          if (key !== "static-v2" && key !== "dynamic") {
            console.log("[Service Wrker] Removing old cache", key);
            return caches.delete(key);
          }
        })
      );
    })
  );
  return self.clients.claim();
});

Пример рабочего кеширования (ссылки вставить свои) более линейным синтаксисом

Пример
const STATIC_CACHE_NAME = "static-v-3";
const DYNAMIC_CACHE_NAME = "dynamic-v-3";

self.addEventListener("install", (event) => {
  const preCache = async () => {
    const cache = await caches.open(STATIC_CACHE_NAME);
    return cache.addAll([
      "/",
      "/index.html",
      "/src/js/material.min.js",
      "/src/js/main.js",
      "/src/css/app.css",
      "/src/css/main.css",
      "https://fonts.googleapis.com/css?family=Roboto:400,700",
      "https://fonts.googleapis.com/icon?family=Material+Icons",
      "https://cdnjs.cloudflare.com/ajax/libs/material-design-lite/1.3.0/material.indigo-pink.min.css",
    ]);
  };
  event.waitUntil(preCache());
});

self.addEventListener("activate", (event) => {
  const clearCache = async () => {
    const cacheKeys = await caches.keys();
    cacheKeys
      .filter((k) => k !== STATIC_CACHE_NAME && k !== DYNAMIC_CACHE_NAME)
      .forEach(async (k) => {
        await caches.delete(k);
      });
  };
  event.waintUntil(clearCache());
  return event.clients.claim();
});

self.addEventListener("fetch", (event) => {
  const tryExtractCache = async () => {
    const cacheRspns = await caches.match(event.request);
    if (cacheRspns) {
      return cacheRspns;
    }
    const rspns = await fetch(event.request);
    const cache = await caches.open(DYNAMIC_CACHE_NAME);
    cache.put(event.request.url, rspns.clone());
    return rspns;
  };
  event.respondWith(tryExtractCache());
});

Web push notifications

Пуш уведомления монжно использовать и без PWA, это очень мощная особенность современных браузеров. Push notifications позволяют отправлять уведомления даже если пользователь выключил браузер, работая по принципу обычных уведомлений из телефонных приложений. При включенном браузере разработчик может управлять полученными уведомлениями с помощью notification API, визуализируя их со специфическими стилями. Такие уведомления позволяют привлечь пользователя, сохраняя активный трафик веб ресурса.

Так образом web push notification позволяет максимально приблизить опыт использования PWA к нативным решениям под android, ios и другие платформы. Web push notification работают внутри система, а не внутри браузера или темболее скоупа html

Поддержка браузерами.

На момент написания данной статьи PWA поддерживаютсяпочти всеми браузерами (за исклюдчением IE)

Использование

Перед использованием уведомлений необходимо запросить права доступа.

Имлементация
<button class="enable-notifications">ENABLE NOTIFICATIONS</button>
var enableNotificationsButtons = document.querySelectorAll(
  ".enable-notifications"
);

function askForNotificationPermission() {
  Notification.requestPermission((result) => {
    console.log('[line 30][app.js] 🚀 result: ', result);
    if (result !== 'granted') {
      console.log('No notification permission granted!')
    }
  });
}

if ("Notification" in window) {
  enableNotificationsButtons.forEach((n) => {
    n.style.display = "inline-block";
    n.addEventListener('click', askForNotificationPermission);
  });
}

После запроса мы можем отправить уведомление, создав класс Notification, данный класс принимает 1 аргументом констурктора текст, 2 специальный js объект с опциями

function displayConfirmNotification() {
  const options = {
    body: "You sucessfully subscribe to our Notification service!",
  };
  new Notification("Successfully subscribed!", options);
}

Обновим функцию показа запроса permissions

function askForNotificationPermission() {
  Notification.requestPermission((result) => {
    console.log("[line 30][app.js] 🚀 result: ", result);
    if (result !== "granted") {
      console.log("No notification permission granted!");
      return;
    }
    // TODO: SHOW NOTIFICATION
    displayConfirmNotification();
  });
}

Интеграция с сервис воркером

Сервис воркер имеет свой интерфейс для взаимодействия с уведомлениямми - showNotification. Переделаем предыдущую функцию для отображения уведомления: Не забудь очистить кеш и перезагрузить страницу!

function displayConfirmNotification() {
  if ("serviceWorker" in navigator) {
    const options = {
      body: "You sucessfully subscribe to our Notification service!",
    };
    navigator.serviceWorker.ready.then((swreg) => {
      swreg.showNotification("Successfully subscribed! (from SW!)", options);
    });
  }
}

Options

Пример настроек wep push notification
    const options = {
      body: "You sucessfully subscribe to our Notification service!",
      icon: "/src/images/icons/app-icon-96x96.png",
      iamge: "/src/images/sf-boat.jpg",
      dir: "ltr",
      lang: "en-UIS", // BCP 47
      vibrate: [100, 50, 200], // vibration ms, not every device support it
      badge: "/src/images/icons/app-icon-96x96.png", // Android, andoid will automatically mask colorfull pics
      tag: "confirm-notification", // Something like ID (for stack notification with same tag)
      renotify: true, // Even use same tag, phone will vibrate
      actions: [
        {
          action: "confirm",
          title: "Okay",
          icon: "/src/images/icons/app-icon-96x96.png",
        },
        {
          action: "cancel",
          title: "Oh no..no-no",
          icon: "/src/images/icons/app-icon-96x96.png",
        },
      ],
    };

body - Текст в теле уведомления

icon - иконка, отображается как на мобильных устройствах, так и на пк

image - превью изображения в теле уведомления, будет показано в ’шторке’ уведомления на телефоне

vibrate - список из number. Время вибрации для мобильных устройств.

badge - маленькая иконка в свернутой шторке уведомлений, работает на android. Умеет автоматически преобразовывать цветные изображения в монохромные.

tag - сущность для группировки однотипных уведомлений, в случае если не хотим беспокоить пользователя указываем одинаковый ID. В таком случае уведомления будут сгруппированы, и не будут беспокоить пользователя.

renotify - если включен тег, мы можем привлечь внимание пользователя легкой вибрацией, не показывая снова уведомления.

Если оба пунка выключены, каждое уведомление будет отображаться как новое.

actions - список из событий, одна из самых важных частей уведомления, необходимая для интерактивного взаимодействия. Принимает массив из объектов, где

action - событие пользователя (cancel/confirm)

title - название события, будет отображено над кликом

icon - иконка рядом с событием.

Отсллеживание событий web push уведомлений

Для отслеживания событий необходимо подписаться на соответствующую функцию обратного вызова: sw.js

self.addEventListener("notificationclick", (event) => {
  const notificaiton = event.notification;
  const action = event.action;
  console.debug("[line 215][sw.js<2>] 🚀 notificaiton: ", notificaiton);

  if (action === "confirm") {
    console.log("Confirm was choosen");
    notification.close();
    return;
  }

  console.log(action);
});

Однако, такой подход обеспечит отслеживание предусмотренных событий. Если пользователь просто закроет уведомление необходимо подписаться на отдельное событие

self.addEventListener('notificationclose', (event) => {
  console.log('Notification was closed', event)
})

Отправка web push уведомлений с сервера

И сточник знаний <-

При отправке уведомлений с сервера необходимо обеспечить 100% гарантию того что уведомления отправляет именно наш сервер. Реализовать подобный функционал средствами javascript невозможно (т.к. он открыт, и кто угодно может видеть его начинку). Однако, существуют решения. Например VAPID, который позволяеют достичшь подобной гарантии, избегая компроментации сообщений.

Интересный факт

Payload в webpush notification ограничен 4 килобайтами.

Бекенд.

Чтобы вдоволь поиграться с возможностью web push notificaiton нам нужен бекенд сервер. Можно использовать firebase с функциями, но не так давно они обновили билинговый план, теперь нельзя использовать firebase для изучения. Поэтому поднимем бекенд сервер локально с помощью дедушки express, а также установим библиотеку web-push. Для тех кто хочет просто что-нибудь скопировать/скачать

yarn add express body-parser web-push

Или для олдов

npm i --save express body-parser web-push

Сгенерируемvapid ключи ./node_modules/.bin/web-push generate-vapid-keys Ну или по православному, package.json

package.json
{
  "name": "server",
  "version": "1.0.0",
  "main": "index.js",
  "scripts": {
    "vapid:generate": "web-push generate-vapid-keys"
  },
  "license": "MIT",
  "dependencies": {
    "body-parser": "^1.19.0",
    "express": "^4.17.1",
    "web-push": "^3.4.5"
  }
}
index.js
//Express
const express = require("express");

//web-push
const webpush = require("web-push");

//body-parser
const bodyParser = require("body-parser");

//path
const path = require("path");

// cors
const cors = require("cors");

//using express
const app = express();

// app.use(cors());
//using bodyparser

app.use(function (req, res, next) {
  res.header("Access-Control-Allow-Origin", "*");
  res.header(
    "Access-Control-Allow-Headers",
    "Origin, X-Requested-With, Content-Type, Accept"
  );
  next();
});
app.use(bodyParser.json());
app.use(
  bodyParser.urlencoded({
    extended: true,
  })
);
//storing the keys in variables
const publicVapidKey =
  "BJxgti75ow0Q96X5fGMIz5PAwdR4a_eGg9KbrpTo8qBaacix5UEMBX0S2amT3HweXWsClr5tsV3EOMYWElIlhOc";
const privateVapidKey = "v0mrf6LhO8U2BzmOkqYa5Te8dPr7ovjOuBU_nddfocU";

//setting vapid keys details
webpush.setVapidDetails(
  "mailto:somerealemal@google.com",
  publicVapidKey,
  privateVapidKey
);

const memcacheSubscriptionStore = [];

//subscribe route
app.post("/subscribe", (req, res) => {
  //get push subscription object from the request
  const subscription = req.body;
  console.debug("[line 33][index.js] 🚀 req: ", req.body);

  //send status 201 for the request
  res.status(201).json({});

  //create paylod: specified the detals of the push notification
  const payload = JSON.stringify({ title: "Hello from node.js backend" });

  setTimeout(() => {
    webpush
      .sendNotification(subscription, JSON.stringify({title: "TIMEOUT MSG", body: "Okay, it works perfect now"}))
      .catch((err) => console.log(err));
  }, 5000);
});

const port = 3010;
app.listen(port, () => {
  console.log(`server started on ${port}`);
});

Фронтенд

Для начала функция вызова показа уведомлений (реализовывали ранее)

Функция показа уведомлений
function displayConfirmNotification(title) {
  if ("serviceWorker" in navigator) {
    const options = {
      body: title || "You sucessfully subscribed! But title not provided.",
      icon: "/src/images/icons/app-icon-96x96.png",
      iamge: "/src/images/sf-boat.jpg",
      dir: "ltr",
      lang: "en-UIS", // BCP 47
      vibrate: [100, 50, 200], // vibration ms, not every device support it
      badge: "/src/images/icons/app-icon-96x96.png", // Android, andoid will automatically mask colorfull pics
      tag: "confirm-notification", // Something like ID (for stack notification with same tag)
      renotify: true, // Even use same tag, phone will vibrate
      actions: [
        {
          action: "confirm",
          title: "Okay",
          icon: "/src/images/icons/app-icon-96x96.png",
        },
        {
          action: "cancel",
          title: "Oh no..no-no",
          icon: "/src/images/icons/app-icon-96x96.png",
        },
      ],
    };
    navigator.serviceWorker.ready.then((swreg) => {
      swreg.showNotification("Successfully subscribed! (from SW!)", options);
    });
  }
}

Теперь реализуем конфигуратор подписки на обновления

Детали
const publicVapidKey =
  "BJxgti75ow0Q96X5fGMIz5PAwdR4a_eGg9KbrpTo8qBaacix5UEMBX0S2amT3HweXWsClr5tsV3EOMYWElIlhOc";

function configurePushSub() {
  if (!("serviceWorker" in navigator)) {
    return;
  }
  let reg;
  navigator.serviceWorker.ready
    .then((swreg) => {
      reg = swreg;
      return swreg.pushManager.getSubscription();
    })
    .then((sub) => {
      console.debug("[line 74][app.js] 🚀 sub: ", sub);
      // if (!sub) {
      // Create a new subscription
      return reg.pushManager.subscribe({
        userVisibleOnly: true, // Only visible for current user
        applicationServerKey: urlBase64ToUint8Array(publicVapidKey), // Our key for determine sender
      });
      // }

      // We have a subscription
    })
    .then((newSub) => {
      console.debug("[line 85][app.js] 🚀 newSub: ", newSub);
      return fetch("http://localhost:3010/subscribe", {
        method: "POST",
        mode: "cors",
        headers: {
          "Content-Type": "application/json",
          Accept: "application/json",
        },
        body: JSON.stringify(newSub),
      });
    })
    .then((res) => {
      return res.clone().json();
    })
    .then((res) => {
      console.debug("[line 101][app.js] 🚀 res: ", res);
      displayConfirmNotification(res.title);
    })
    .catch((err) => {
      console.warn("[line 101][app.js] 🚀 err: ", err);
    });
}
Ну и сам запрос пермишенсев (повешать колбеком на какое-нибудь событие)
Детали
function askForNotificationPermission() {
  Notification.requestPermission((result) => {
    console.log("[line 30][app.js] 🚀 result: ", result);
    if (result !== "granted") {
      console.log("No notification permission granted!");
      return;
    }
    // TODO: SHOW NOTIFICATION
    // displayConfirmNotification();
    configurePushSub();
  });
}

Обработка события

Реализуем обработку события, по клику на уведомления откроем существующий таб (сделаем видимым через focus), либо создадим новый. sw.js

Обработчик событий клика на уведомление
self.addEventListener("notificationclick", (event) => {
  const notificaiton = event.notification;
  const action = event.action;
  console.debug("[line 215][sw.js<2>] 🚀 notificaiton: ", notificaiton);

  if (action === "confirm") {
    console.log("Confirm was choosen");
    notification.close();
    return;
  }
  event.waitUntil(
    // All winwows
    clients
      .matchAll()
      .then((clis) => {
        const client = clis.find((cl) => cl.visibilityState === "visible");
        if (client) {
          client.navigate("http://localhost:8083");
          client.focus();
          return;
        }
        clients.openWindow("http://localhost:8083");
      })
      .then(() => {
        notificaiton.close();
      })
  );

  console.log(action);
});

Native mobile device features

Захват камеры

<video id="player" autoplay></video>
<canvas id="canvas" width="320px" height="240px"></canvas>
<button class="mdl-button mdl-js-button mdl-button--raised mdl-button--colored" id="capture-btn">Capture
</button>

Для доступа к медиа устройству (аудио + видео, видео вкючает в себя работу с фото) используется функциналmediaDevices Опишем полифил для работы с mediaDevices

mediaDevices полифил
function initalizeMedia() {
  if (!('mediaDevices' in navigator)) {
    navigator.mediaDevices = {};
  }
  if (!('getUserMedia' in navigator.mediaDevices)) {
    navigator.mediaDevices.getUserMedia = (constraints) => {
      const getUserMedia = navigator.webkitGetUserMedia || navigator.mozGetUserMedia;
      if (!getUserMedia) {
        return Promise.reject(new Error('getUserMedia is not implemented'));
      }

      return new Promise((resolve, reject) => {
        return getUserMedia.call(navigator, constraints, resolve, reject);
      });
    }
  }
}

Как работает этот полифил? Весьма просто, если mediaDevices неподдерживаются мы создаем пустй объект, затем проверяем есть ли webkit или moz реализация, если есть устанавливаем ее для getUserMedia функции. В противном случае создаем промис с негативным результатом (райзим ошибку) initalizeMedia можно вызвать в любом удобном месте.

При открытии страницы браузер должен запросить в 1 раз разрешение на использованием камеры. После этого должно появится изображение.

Повесим экшен на кнопку, кудаж без него

Click event listener
captureButton.addEventListener("click", (event) => {
  canvasElement.style.display = "block";
  videoPlayer.style.display = "none";
  captureButton.style.display = "none";
  const context = canvasElement.getContext("2d");
  context.drawImage(
    videoPlayer,
    0,
    0,
    canvasElement.width,
    videoPlayer.videoHeight / (videoPlayer.videoWidth / canvas.width)
  );
  videoPlayer.srcObject.getVideoTracks().forEach((track) => {
    track.stop();
  });
});

Тут из интересного videoPlayer.videoHeight / (videoPlayer.videoWidth / canvas.width), по факту это просто вычисление соотношения сторон.

Для конвертации нашей картинке нам необходима функция преобразования URL в блоб (если по какой-то причине вам не досталась ваша версия copilot то пжалуста):

Для тех кому не достался его маленький copilot
function dataURItoBlob(dataURI) {
    // convert base64/URLEncoded data component to raw binary data held in a string
    var byteString;
    if (dataURI.split(',')[0].indexOf('base64') >= 0)
        byteString = atob(dataURI.split(',')[1]);
    else
        byteString = unescape(dataURI.split(',')[1]);

    // separate out the mime component
    var mimeString = dataURI.split(',')[0].split(':')[1].split(';')[0];

    // write the bytes of the string to a typed array
    var ia = new Uint8Array(byteString.length);
    for (var i = 0; i < byteString.length; i++) {
        ia[i] = byteString.charCodeAt(i);
    }

    return new Blob([ia], {type:mimeString});
}

теперь можно преобразовать наш канвас в блоб picture = dataURItoBlob(canvasElement.toDataURL());

Для того чтобы остановить запись с камеры:

  if (videoPlayer.srcObject) {
    videoPlayer.srcObject.getVideoTracks().forEach(function (track) {
      track.stop();
    });
  }

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

Получение геолокации пользователя

Скрипт получения геолокации
function initializeLocation() {
  if (!("geolocation" in navigator)) {
    locationBtn.style.display = "none";
    // Сервис геологкации недоступен
  }
  navigator.geolocation.getCurrentPosition(
    (position) => {
      console.debug("[line 36][feed.js] 🚀 position: ", position);
      restoreLocationView();
      fetechedLocation = position.coords;
      locationInout.value = JSON.stringify(fetechedLocation);
      locationInput.classList.add("is-focused");
    },
    (err) => {
      console.log(err);
      restoreLocationView();
      fetchedLocation = null;
      alert("Couldn't fetch location, please enter manually");
    },
    {
      timeout: 7000, // 7 секунд на нахождение позиции.
    }
  );
}

Что представляет из себя функцтя getCurrentPosition 1 аргумент - функция обратного вызова с упешно полученный геопозицией 2 аргумент - колбек для перехвата ошибки 3 - объект конфигурации, где timeout - количество секунд перед ошибкой.

Background синхронизация

Background необходима для того, чтобы данные можно было сложнее синхронизировать чтобы можно было менять контент без реального доступа к сети. Данные меняются локально, сохраняясь в промежуточный кеш. После подключения к сети срабатывает специальный метод внутри сервис воркера - sync, внутри которого можно передать данные из промежуточного хранилища на наш сервер.

Кроме того, помнишь проблему с тем что юзер может отправить запрос и сразу уйти со страницы? Данные будут неконсистентны. Пользователь считает что все нормально, на самом же деле запрос не успел выполниться (большой ttfb, медленный интернет, большое тело запроса и тд.). Фоновая синхронизация решает эту проблему. Т.к. она работает с закрытой вкладкой.

За синхронизацию данных отвечаетSyncManager. Сооствественно любая работа с функицоналом должна начинаться с проверки поддержки браузером: if ("serviceWorker" in navigator && "syncManager" in window) {

К несчастью, на момент написания статьи, большинство браузеров не поддерживают данный функционал

Ссылки

Реализация фоновой синхронизации при выходе из offline

Реализация представляет собой обычный прокси. Все запросы, вместо отправки на сервер, сохраняются в промежуточное хранилище. В нашем случае промежуточное хранилище - indexed db.

После чего можно синхронизировать хранилище с сервером (или firebase)

Отправка запроса из бизнес логики
form.addEventListener("submit", function (event) {
  event.preventDefault();
  event.stopPropagation();

  if (titleInput.value.trim() === "" || locationInput.value.trim() === "") {
    alert("Please enter valid data!");
    return;
  }

  closeCreatePostModal();

  if ("serviceWorker" in navigator && "SyncManager" in window) {
    navigator.serviceWorker.ready.then((sw) => {
      const post = {
        id: new Date().toISOString(),
        title: titleInput.value,
        location: locationInput.value,
      };
      // Store indexdb
      writeData("sync-posts", post)
        .then(() => {
          return sw.sync.register("sync-posts");
        })
        .then(() => {
          const snackbar = document.querySelector("#confirmation-toast");
          const data = { message: "Your Post was save for syncing!" };
          snackbar.MaterialSnackbar.showSnackbar(data);
        });
    });
    return;
  }
  // Service worker unsupported block
  sendData();
});

Синронизация происходит в 2 случаях: 1 - при восстановлении соединения с интернетом, 2 - периодическая синхронизация.

Синхронизация при восстановлении соединения

self.addEventListener("sync", (event) => {
  console.log("[Service Worker] Background syncing", event);
  if (event.tag === "sync-posts") {
    console.log("[Service Worker] Syncing new posts");
    event.waitUntil(
      readAllData("sync-posts").then((data) => {
        data.forEach((d) => {
          fetch("https://react-23388.firebaseio.com/posts.json", {
            method: "POST",
            headers: {
              "Content-Type": "application/json",
              Accept: "application/json",
            },
            body: JSON.stringify({
              id: d.id,
              title: d.title,
              location: d.location,
            }),
          })
            .then((res) => {
              console.log("Sent data", res);
              if (res.ok) {
                deleteItemFromData("sync-posts", d.id);
              }
            })
            .catch((err) => {
              console.log("Error while sending data.");
            });
        });
      })
    );
  }
});

Периодическая PWA синхронизация

На момент напсания статьи не поддерживается ни в 1 из браузеров Впрочем, достичь ее можно с помощью dedicated worker и очереди (например взятой из rxjs)

Workbox

Инструмент для упращения управления service worker.Подробнее про workbox можно посмотреть тут

Install

2 версия

npm install --save-dev workbox-cli@^2

Добавим скрипт для генерации. package.json

  "scripts": {
    "start": "http-server -c-1 -p 8083",
    "generate-sw": "workbox generate:sw"
  },

Запускаем. Следуем инструкции. В процессе вводим имя для service worker, я назвал service-worker.js, после установки регестрируем именно этот файл

if ("serviceWorker" in navigator) {
  navigator.serviceWorker
    .register("/service-worker.js")
    .then(function () {
      console.log("Service worker registered!");
    })
    .catch(function (err) {
      console.log(err);
    });
}

Однако, данные метод закешировал только то что хранится в статике, игнорируя динамический кеш. Это легко проверить, достаточно включить offline режим и перезагрузить страницу. Кроме того, workbox закешировал и часть статических файлов, которые не нуждались в кешировании.

Чтобы исправить это настроим паттерны в workbox-cli-config.js Например, вместого того чтобы кешировать все изображения будем кешировать только те, что находятся в images директории. Также, предположим что не хотим кешировать файлы из help директории

module.exports = {
  globDirectory: "public/",
  globPatterns: ["**/*.{ico,html,json,css,js}", "src/images/*.{jpg,png}"],
  swDest: "public/service-worker.js",
  globIgnores: ["../workbox-cli-config.js", "help/**"],
};

Вместо генерации service-worker целиком можно переиспользовать базовый файл. Для начала удалим сгенерированный ранее service-worker.js и создадим sw-base.js:

importScripts("workbox-sw.prod.v2.1.3.js");

const workboxSW = new self.WorkboxSW();
workboxSW.precache([]);

Пустой прекеш будет заменне реальными файлами, в скрипт json заменим generate-sw

  "scripts": {
    "start": "http-server -c-1 -p 8083",
    "generate-sw": "workbox inject:manifest"
  },

Обновим workbox-cli-config

{
  globDirectory: "public/",
  globPatterns: ["**/*.{ico,html,json,css,js}", "src/images/*.{jpg,png}"],
  swSrc: "public/sw-base.js",
  swDest: "public/service-worker.js",
  globIgnores: ["../workbox-cli-config.js", "help/**"],
}

Ну и синхронизируем yarn generate-manifest

Динамическое кеширование с помощью workbox

Сейчас workbox кеширует только статику, оно конечно прекрасно, но если зайти в оффлайн то половина шрифтов и стилей отсутствует. Любого линуксоида оно конечно устроит, но давайте вернем все как было

sw-base.js

const workboxSW = new self.WorkboxSW();

workboxSW.router.registerRoute(/.*(?:googleapis|gstatic)\.com.*$/,
  workboxSW.strategies.staleWhileRevalidate({
    cacheName: 'google-fonts'
  }));

workboxSW.precache([]);

Из приятного, регистратор роута принимает регекспы.

staleWhileRevalidate cache then network. Иными словами, если нет в кеше данных то делаем запрос, после выполнения fetch кешируем

Также роут моет принимать кастомную имплементацию в виде колбека

Например
workboxSW.router.registerRoute("http://localhost:3010/posts", (args) => {
  return fetch(args.event.request).then(function (res) {
    var clonedRes = res.clone();
    clearAllData("posts")
      .then(function () {
        return clonedRes.json();
      })
      .then(function (data) {
        for (var key in data) {
          writeData("posts", data[key]);
        }
      });
    return res;
  });
});

Кроме того, первый аргумент также может быть колбеком, в котором можно имплементить более сложную логику, например кешировать только text/html запросы

#+START_SPOILER Кеширование только text/html >

workboxSW.router.registerRoute(
  (routeData) => {
    return routeData.event.request.headers.get("accept").includes("text/html");
  },
  (args) => {
    return caches.match(args.event.request).then(function (response) {
      if (response) {
        return response;
      } else {
        return fetch(args.event.request)
          .then(function (res) {
            return caches.open("dynamic").then(function (cache) {
              // trimCache(CACHE_DYNAMIC_NAME, 3);
              cache.put(args.event.request.url, res.clone());
              return res;
            });
          })
          .catch(function (err) {
            return caches.match("/offline.html").then(function (cache) {
              if (
                args.event.request.headers.get("accept").includes("text/html")
              ) {
                return cache.match("/offline.html");
              }
            });
          });
      }
    });
  }
);

#+CLOSE_SPOILER

Еще немного про workbox

Хорошой новостью является то, что мы можем писать обычный код нашем base worker после workboxSW.precache Все примеры доступны на github

PWA
forntend
typescript
js
progressive web app
0
3651