Browser push notification

Chào các bạn, như đã hứa trong group Laravel Việt Nam thì hôm nay mình tranh thủ viết một bài tutorial giới thiệu cách implement Push notification trên browser. Ở đây mình sẽ chỉ giới thiệu cách implement trên trình duyệt Chrome/Cốc Cốc. Firefox hoặc safari thì cũng tương tự các bạn có thể tự tìm hiểu thêm hoặc nếu có nhiều bạn có nhu cầu thì mình xin trình bày ở 1 blog khác. Mình viết hơi dài một chút để các bạn có thể hiểu sâu vấn đề chứ không chỉ là step by step tutorial mà code nó chạy mình không hiểu tại sao.

Chào các bạn, như đã hứa trong group Laravel Việt Nam thì hôm nay mình tranh thủ viết một bài tutorial giới thiệu cách implement Push notification trên browser. Ở đây mình sẽ chỉ giới thiệu cách implement trên trình duyệt Chrome/Cốc Cốc. Firefox hoặc safari thì cũng tương tự các bạn có thể tự tìm hiểu thêm hoặc nếu có nhiều bạn có nhu cầu thì mình xin trình bày ở 1 blog khác. Mình viết hơi dài một chút để các bạn có thể hiểu sâu vấn đề chứ không chỉ là step by step tutorial mà code nó chạy mình không hiểu tại sao.

*Note, mình base trên article này của Google và có chỉnh sửa cho phù hợp với website Nhớ Lịch Âm, nếu bạn nào giỏi tiếng anh thì hoàn toàn có thể qua đó đọc và bỏ qua bài viết này của mình.

Đầu tiên ta phải hiểu Push Notification là cái gì. Mình tin là bây giờ mobile quá nhiều rồi nên việc nhận notification từ các app trên điện thoại là điều hiển nhiên ai cũng thấy. Nhưng kể cả trên điện thoại cũng có 2 loại Notification đó là local notification và push notification. Local có nghĩa là tự bản thân cái app đó sinh ra notification ví dụ ứng dụng hẹn giờ, game đã đủ energy để chơi tiếp… thì cứ đến lúc đó nó sẽ thông báo kể cả khi máy không có internet. Còn push notification thì ứng dụng không tự sinh ra mà do server sinh ra và đẩy xuống client thông qua một cổng cloud messaging server của Apple/Google. Từ các cổng này sẽ thông báo xuống hệ điều hành Android/iOS… là có thông báo mới và hãy hiển thị đi. Nên có internet thì mới có push notification.

Browser push notification cũng tương tự như vậy. Ta sẽ gửi notification xuống browser thông qua một cổng cloud messaging nào đó (gọi là endpoint). Có thể là cổng của Google cho Chrome, Apple cho Safari hay của Mozzila cho Firefox. Tuy nhiên mỗi browser và mỗi cloud messaging server lại có những cách hoạt động riêng. Thực ra các ông lớn đang cố gắng định nghĩa ra 1 chuẩn chung đó là Web Push Protocol. Tuy nhiên mới chỉ có Firefox Nighty là đang phát triển theo hướng này và có thể sẽ là browser đầu tiên hỗ trợ Web Push Protocol.

Trong blog này, chúng ta sẽ tìm hiểu cách gửi 1 thông báo thông qua Google cloud messaging xuống trình duyệt Chrome. Vì Chrome chưa theo chuẩn chung Web Push Protocol nên vẫn phải đi lòng vòng qua khá nhiều bước authorization và hạn chế mà mình sẽ giới thiệu sau. Khi nhận được thông báo thì browser sẽ gọi ra 1 đoạn mã Javascript gọi là “service worker” mà chúng ta đã chỉ định từ trước để xử lý sự kiện này. Khi đó service worker của chúng ta sẽ xem đó là thông báo gì và hiển thị Push Notification tương ứng lên cho user xem. Ok, về cơ bản là như vậy, bây giờ chúng ta sẽ đi vào từng bước 1. Đầu tiên đó là đăng ký một project trên Google developer console

1. Đăng ký project trên Google developer console.

    – Bước 1: Vào trang https://console.developers.google.com/ Bạn sẽ thấy màn hình gì đó kiểu này:

    – Bước 2: Tạo một project trên đó, bạn sẽ ra 1 màn hình kiểu này:

    – Bước 3: Bạn gõ vào ô tìm kiếm “Google Cloud Messaging” và bấm vào kết quả đầu tiên nó sẽ ra trang sau:

    – Bước 4, Enable, nó sẽ ra trang sau:

    – Bước 5: Như bạn thấy nó bảo mình hãy vào credential đi, nên cứ vào theo thôi. Sau khi vào, bấm vào “What credentials do I need?” nó sẽ tự sinh ra 1 API key như sau:


Lưu cái key này lại và bạn sẽ cần nó khi gửi thông báo xuống client ở các bước sau. Nó có lưu ý bạn hãy restrict key là để chỉ cho phép gửi notificaiton từ 1 server hay domain cố định nào đó nhưng bạn đang muốn test ở localhost nên chưa cần vội.


Bạn hãy lưu lại số Project number để dùng cho các bước sau

2. Tạo file manifest.json

Như mình có đề cập ở trên thì vì Chrome sử dụng GCM nên nó phải qua authorization khá loằng ngoằng. Nên bạn sẽ phải tạo một file manifest.json có chưa Project number mà bạn vừa tạo ở bước trên với nội dung như sau:


{
  "name": "Nhớ Lịch Âm",
  "gcm_sender_id": "10431xxxxxxx"
}

Trong đó name là website của bạn, gcm_sender_id là project number lấy ra từ Google developer console. Trên thực tế bạn có thể cho thêm nhiều thông tin khác như icon,… nhưng mình chỉ để như vậy cho đơn giản.
Tiếp theo là phải để file manifest.json vào web root hoặc folder public trong Laravel và include file này vào website của chúng ta ở head như sau:


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

3. Đăng ký service worker

Vậy cụ thể service worker là cái gì và tại sao lại cần đến nó. Hãy tưởng tượng push notification có thể được gửi đến bất kỳ lúc nào kể cả khi website của chúng ta không được mở. Ví dụ bạn enable push notification trên trang Product Hunt thì kể cả không bao giờ mở trang web đó ra lần thứ 2, bạn vẫn có thể nhận và hiện push notification của product hunt thông báo có sản phẩm mới bình thường. Vậy khi website của ta không mở thì cái gì sẽ chạy khi có push notification mới? Đó chính là service worker. Đơn giản nó là 1 đoạn mã Javascript lắng nghe các sự kiện của push notification. Lý do gọi là service là vì nó sẽ chạy ngầm trong background và được gọi ra bất cứ khi nào cần. Đoạn code sau đây dùng để đăng ký 1 service worker như thế vào browser:


window.addEventListener('load', function() {
    // Check that service workers are supported, if so, progressively  
    // enhance and add push messaging support, otherwise continue without it.  
    if ('serviceWorker' in navigator) {  
        navigator.serviceWorker.register('/service-worker.js')  
        .then(initialiseState);  
    } else {  
        console.warn('Service workers aren\'t supported in this browser.');  
    }
});

Trong đoạn code trên, đầu tiên ta check xem browser này có hỗ trợ push notification hay không (một số trình duyệt trên điện thoại hoặc chạy ở chế độ private incognito mode sẽ không support), nếu có thì chúng ta sẽ đăng ký một file service-worker.js chứa logic để xử lý push notification cho website của chúng ta. Lưu ý là file này phải nằm ở thư mục root của website, tức là thư mục public trong Laravel, nếu không thì sẽ có lỗi gì đó mà mình cũng không nhớ lắm.
Thông thường chúng ta sẽ đặt một button trên website cho phép người dùng bật/tắt push notification nếu muốn. Chúng ta sẽ đặt trạng thái hiện tại cho button này là bật hay tắt trong hàm initialiseState sau đây. Tuy nhiên ở website Nhớ Lịch Âm, cho đơn giản, mình chỉ hiện popup mặc định của browser nên mình bỏ qua phần này. Nếu bạn muốn xem thì có thể sang link của Google ở đầu bài viết. Thay vào đó, chúng ta sẽ dùng hàm initialiseState để subscribe push notification như sau:

4. Subscribe push notification

Ở bước trên chúng ta mới chỉ đăng ký 1 service worker cho website của mình. Service worker này có thể sử dụng để làm rất nhiều việc không chỉ có xử lý push notification. Điều đó có nghĩa là chỉ đăng ký service worker thôi là chưa đủ, ta còn phải subscribe service worker đó để xử lý 1 việc cụ thể là push notification.


// Once the service worker is registered set the initial state  
function initialiseState() {  
  // Are Notifications supported in the service worker?  
  if (!('showNotification' in ServiceWorkerRegistration.prototype)) {  
    console.warn('Notifications aren\'t supported.');  
    return;  
  }

  // Check the current Notification permission.  
  // If its denied, it's a permanent block until the  
  // user changes the permission  
  if (Notification.permission === 'denied') {  
    console.warn('The user has blocked notifications.');  
    return;  
  }

  // Check if push messaging is supported  
  if (!('PushManager' in window)) {  
    console.warn('Push messaging isn\'t supported.');  
    return;  
  }
  // We need the service worker registration to check for a subscription  
  navigator.serviceWorker.ready.then(function(serviceWorkerRegistration) {  
    // Do we already have a push message subscription?  
    serviceWorkerRegistration.pushManager.getSubscription()  
      .then(function(subscription) {  
        if (!subscription) {  
          subscribe();
          return;  
        }
        // Keep your server in sync with the latest subscriptionId
        sendSubscriptionToServer(subscription);
      })  
      .catch(function(err) {  
        console.warn('Error during getSubscription()', err);  
      });  
  });  
}

Như bạn thấy ở trên, bạn có thể check xem push notification có được hỗ trợ hay không, có bị user block hay không… và update UI bật hay tắt tương ứng. Nhưng chúng ta tạm thời chỉ log ra warning và return luôn mà không quan tâm đến UI, chỉ quan tâm tới việc làm sao để subscribe push notification. Sau khi đăng ký service worker thành công chúng ta sẽ có 1 đối tượng serviceWorkerRegistration cho phép ta subscribe nếu như chưa subscribe bao giờ, nếu như đã subscribe rồi thì vẫn gửi subscription id lên server (hay còn gọi là endpoint). Bạn có thể băn khoăn tại sao lần nào cũng phải gửi subscription id lên server? Bởi vì có thể người dùng đổi browser, cài lại máy tính… nên lần nào cũng gửi để đảm bảo đó là cái browser cuối cùng mà người dùng đang sử dụng.


function subscribe() {
  navigator.serviceWorker.ready.then(function(serviceWorkerRegistration) {  
    serviceWorkerRegistration.pushManager.subscribe({userVisibleOnly: true})  
      .then(function(subscription) {
        return sendSubscriptionToServer(subscription);  
      })  
      .catch(function(e) {  
        if (Notification.permission === 'denied') {  
          // The user denied the notification permission which  
          // means we failed to subscribe and the user will need  
          // to manually change the notification permission to  
          // subscribe to push messages  
          console.warn('Permission for Notifications was denied');  
        } else {  
          // A problem occurred with the subscription; common reasons  
          // include network errors, and lacking gcm_sender_id and/or  
          // gcm_user_visible_only in the manifest.  
          console.error('Unable to subscribe to push.', e); 
        }  
      });  
  });  
}

function sendSubscriptionToServer(subscription) {
    $.ajax({
        url : "/save-endpoint",
        type : "post",
        dateType:"text",
        data : {
            endpoint : subscription.endpoint
        }
    });
}

Cụ thể thì 2 hàm trên sẽ giúp chúng ta subscribe và gửi endpoint lên server. Nó cũng khá dễ hiểu nên mình không giải thích dài dòng nữa. Chỉ có 1 điểm chú ý là khi subscribe phải có option userVisibleOnly set là true nếu không thì Chrome sẽ không chấp nhận ở thời điểm hiện tại. Option này để đảm bảo rằng service worker sẽ không xử lý ngầm gì đó mà user không nhìn thấy. Bạn có thể tạm thời không cần quan tâm đến nó chỉ cần chú ý rằng sau này dù có lỗi xảy ra bạn vẫn phải hiện ra notification cho user nhìn thấy kiểu như: vừa có lỗi xảy ra hoặc có lỗi xảy ra nên không lấy được đúng notification. Nếu không thì chính browser sẽ hiện ra một notification lỗi mặc định nào đó.

Kết quả là bạn sẽ có 1 endpoint kiểu như sau gửi lên server:


https://android.googleapis.com/gcm/send/APA91bHPffi8zclbIBDcToXN_LEpT6iA87pgR-J-MuuVVycM0SmptG-rXdCPKTM5pvKiHk2Ts-ukL1KV8exGOnurOAKdbvH9jcvg8h2gSi-zZJyToiiydjAJW6Fa9mE3_7vsNIgzF28KGspVmLUpMgYLBd1rxaVh-L4NDzD7HyTkhFOfwWiyVdKh__rEt15W9n2o6cZ8nxrP

Như bạn thấy, đó chính là 1 URL đến cổng Google Cloud Messaging (gcm). Chúng ta sẽ dùng URL này để gửi notification đến browser này từ phía server bằng cách tạo một POST request đến URL. Mình sẽ đề cập sau. Nhưng trước tiên, bạn phải tạo một route trên Laravel và Controller tương ứng để lưu endpoint này lại. Lưu ý nó có thể có độ dài lớn hơn 255 (độ dài mặc định của string trong mysql), nên bạn có thể phải để dài hơn trong file migration ví dụ:


$table->string('endpoint', 300)->nullable();

Lưu ý ở đây mình lưu tất cả endpoint URL vì mình có thể có các cổng messaging khác ví dụ như cổng sau nếu bạn dùng Firefox:


https://updates.push.services.mozilla.com/wpush/v1/gAAAAABXunrB1m4Lk5P4EuA37P9jsliYEjSWKi3VaIzb9xIe3umy-WUWKOKlKUtgdDM9sU5pESlo2CtkwXD4dFBdhDE4DfJ4dAo-s-HLoQNdtIlrzrEQ1d-Yy_Lf6CYHMVfreppuKMAp'

Tuy nhiên nếu bạn chỉ support Chrome, thì bạn chỉ cần lưu lại phần registration ID ở cuối của URL còn phần đầu URL là như nhau cho mọi endpoint.
Ok, gộp tất cả các đoạn javascript ở bên trên vào 1 file và include vào trong trang web của bạn là bạn đã sẵn sàng cho bước tiếp theo.

5. Gửi notification từ phía server

Sau khi đã có endpoint thì bạn sẽ tạo một POST http request đến endpoint đó để gửi push notification. Bạn có thể dùng bất kỳ lib nào trên laravel để làm điều này ví dụ Guzzle, nhưng mình sử dụng curl cho nó cơ bản như sau:


$curl = curl_init($endpoint);
curl_setopt($curl, CURLOPT_CUSTOMREQUEST, "POST");
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
curl_setopt($curl, CURLOPT_HTTPHEADER, [
    'TTL: 60',
    'Content-Type: application/json',
    'Content-Length: 0',
    'Authorization: key=' . Consts::GOOGLE_PROJECT_API_KEY
]);
$result = curl_exec($curl);

Trong đó GOOGLE_PROJECT_API_KEY chính là key mà bạn đã setup trên Google developer console từ bước 1.
Nếu bạn để ý thì sẽ thấy là mình không gửi bất kỳ một thông tin gì xuống client ví dụ như nội dung notification là gì vậy làm sao để hiện? Đó là vì Chrome chưa hoàn toàn support Web Push Protocol, nên nó không cho phép bạn gửi bất kỳ payload data gì kèm theo xuống client thông qua GCM. Thay vì thế bạn sẽ phải dùng REST API để lấy dữ liệu từ phía server của chính bạn.
Lý do để Google cấm việc truyền dữ liệu xuống client trong khi Web Push Protocol lại cho phép là bởi vì đáng ra bạn phải encrypt mọi dữ liệu trước khi gửi tới endpoint. Có nghĩa là cái cổng cloud messaging là người nằm ở giữa server của bạn và client không được phép biết thông tin bạn đang gửi đi chứa những thông tin gì. Nó phải được encrypt trên server trước khi gửi và decrypt ở client khi nhận. Nếu sau này Chrome cũng hỗ trợ web push protocol thì sẽ có thể được encrypt như vậy còn bây giờ thì chưa.

6. Xử lý sự kiện khi nhận được push notification từ phía server

Tất cả những đoạn code dưới đây nằm trong file service-worker.js. Ở những bước trên ta mới chỉ đăng ký nó còn bây giờ chúng ta mới thực sự cài đặt 1 service worker hoạt động như thế nào.


self.addEventListener('push', function(event) {
  var apiPath = '/browser_pn?endpoint=';

  event.waitUntil(
    registration.pushManager.getSubscription()
    .then(function(subscription) {
      if (!subscription || !subscription.endpoint) {
        throw new Error();  
      }

      apiPath = apiPath + encodeURI(subscription.endpoint);

      return fetch(apiPath)
      .then(function(response) {
        if (response.status !== 200){
          console.log("Problem Occurred:"+response.status);
          throw new Error();
        }

        return response.json();
      })
      .then(function(data) {
        if (data.status == 0) {  
          console.error('The API returned an error.', data.error.message);  
          throw new Error();  
        }  
        var data = data.data;

        var title = data.notification.title;  
        var message = data.notification.message;  
        var icon = data.notification.icon;  
        var data = {
          url: data.notification.url
        };

        return self.registration.showNotification(title, {  
          body: message,  
          icon: icon,
          data: data
        });
      })
      .catch(function(err) {
        return self.registration.showNotification('Notification', {
          body: 'Có một sự kiện sắp diễn ra',
          icon: '/image/pn_logo.png',
          data: {
            url: "/"
          }
        });
      });
    })
  );
});

Như mình đã nhắc ở trên thì do server không trả về bất kỳ data nào nên chúng ta buộc lòng phải gọi thêm 1 request nữa lên server để lấy ra message cần hiển thị. Và dĩ nhiên chúng ta sẽ phải gửi kèm endpoint lên để phân biệt là message đó của user nào. Nếu như mọi user đều như nhau nghĩa là message chỉ có tính thời điểm mà không phụ thuộc vào user thì bạn có thể không cần get ra endpoint khi request. Đoạn code sau trả về subscription mà service worker này đã đăng ký ở trên cho Push notification:


    registration.pushManager.getSubscription()

Sau khi có được endpoint rồi thì bạn gửi request lên server. Ở đây mình không dùng ajax nữa bởi vì service worker này chạy độc lập tức là kể cả khi website của mình không được mở nó vẫn phải chạy được nên không hề import jquery vào. Request này thì không có gì đặc biệt, ở đây mình dùng request ‘/browser_pn?endpoint=your_endpoint’ nhưng bạn hãy tự định nghĩa riêng cho mình 1 cái như thế trong routes.php và controller tương ứng và trả về data tương ứng. Phần sau đó thì mình nghĩ là dễ hiểu rồi vì chỉ đơn thuần là parse data trả về bao gồm có message, title, icon, url để khi bấm vào thông báo sẽ mở ra trang web nào đó.
Có 1 điểm chú ý như bạn thấy đó là ở chỗ catch exception mình vẫn phải hiện 1 thông báo bởi vì kể cả mình không hiện thì Chrome cũng tự hiện 1 cái vớ vẩn nào đó.

7. Xử lý sự kiện khi user click vào thông báo push notification


self.addEventListener('notificationclick', function(event) {
    event.notification.close();
    var url = event.notification.data.url;
    event.waitUntil(
        clients.matchAll({
                type: 'window'
            })
            .then(function(windowClients) {
                for (var i = 0; i < windowClients.length; i++) {
                    var client = windowClients[i];
                    if (client.url === url && 'focus' in client) {
                        return client.focus();
                    }
                }
                if (clients.openWindow) {
                    return clients.openWindow(url);
                }
            })
    );
});

Phần này thì chắc là không có gì để giải thích nhiều. Đơn giản là khi click vào thông báo sẽ mở ra trang web tương ứng. Nếu như nó đã được mở rồi thì focus vào tab đó nếu chưa thì mở tab mới.

That’s it. Vậy là tới đây coi như bạn đã hoàn thành xong việc implement push notification trên trình duyệt Chrome/Cốc Cốc. Cảm ơn các bạn đã có đủ kiên nhận đọc tới tận đây. Nếu bạn nào muốn implement trên các trình duyệt khác thì chắc phải tự tìm hiểu về Web Push Protocol, nó cũng tương tự như vậy thôi. Bài viết này hoàn toàn trên hiểu biết cá nhân và có thể sai/thiếu sót nên các bạn cứ nhiệt tình comment gạch đá nhé.