LoginSignup
11
13

More than 1 year has passed since last update.

Push通知を理解するためのサンプル

Last updated at Posted at 2021-10-15

はじめに

世の中のサンプルがいまいち理解できなかったため、自分なりに書き下したサンプルです。

  • ServiceWorkerを利用したPush通知サンプル
  • Firebaseは使っていません
  • web-pushというライブラリを利用
  • curlから送信できるように、画面にコマンドを表示します(画面から送信もできます)

img2.png

ソースはこちら

概要

  1. webサーバ(express, ts-node)
    • 下記2つのhtmlを返します。
    • index.html
    • sendPush.html
    • Push通知を受け取り、EndPointに送信します。
  2. index.html

    • ServiceWorkerを登録後、Push通知を購読します(EndPoint表示)
    • 画面にPush送信ができるcurlコマンドを表示します
  3. sendPush.html(Push送信メッセージ入力して、Webサーバに送信する)

    • PushメッセージWebサーバに送信 ⇒ EndPointに送信 ⇒ index.htmlのServiceWorkerに通知します。

構成

files.png

フォルダ ファイル名 説明
/ app.ts public配下のstaticファイルを返すWebサーバ(express)
generateKey.ts VAPID用のキーペアvapidKey.jsonを生成するためのスクリプト(npm run generateKey)
public index.html ServiceWorkerの登録を行い、Pushメッセージ受け取るための画面
index.js
sendPush.html Push送信メッセージ入力して、Webサーバに送信するための画面
sendPuth.js
svc_worker.js Push受信を行うためのサービスワーカー
route/api webpush.ts index.htmlから受け取ったPush通知用EndPointの保存と、EndPointへのPush通知を行うApi(express)

Technology stack

  • ブラウザ(JavaScript)

    • service Worker
  • webサーバ(TypeScript)

    • TypeScript
    • ts-node
    • express
    • web-push

利用前の準備作業

パッケージのインストール
bash
npm install

VAPID用のキーペアを生成します。vapidKey.jsonというファイル名で保存されます。

npm run generateKey

処理シーケンス

  1. サーバ側で鍵ペアを生成する(最初の1回のみ)
  2. 以降は下記のシーケンスで動きます

sequence.png

実行手順

  • webサーバを起動します
npm run dev
  • localhost:3000をブラウザで開きます(index.html)

img1.png

  • 登録ボタンを押下することで、サーバから公開キーの取得と、Pushサービスの購読を行います。

(endpointを含む、PushSubscriptionを入手します)

img2.png

  • 画面にcurl用テストコマンドと、Push送信画面を開くボタンが表示されます。

img3.png

  • この画面でメッセージを入力して送信すると、通知が表示されます。

img4.png

主要ソースの説明(ブラウザ側)

index.html (ServiceWorkerの登録を行い、Pushメッセージ受け取るための画面)

<!doctype html>
<html lang="ja">

<head>
  <meta charset="utf-8">
  <title>Push通知サンプル</title>
  <!-- Include manifest -->
  <!-- <link rel="manifest" href="manifest.json"> -->
</head>

<body>
  <h1>Push通知サンプル</h1>

  <div id="install-svcw" style="display:none;">
    Service Workerは登録されていません。
    <button id="install_svcworker">登録</button>
  </div>
  <div id="uninstall-svcw" style="display:none;">
    Service Workerを登録済みです。
    <button id="uninstall_svcworker">登録解除</button>

    <p>curlテスト用コマンド(curlでPush通知を送信できます)</p>
    <div style="word-break: break-all;border: 1px solid black;margin: 6px;padding:6px;">
      <code class="js-subscription-json"></code>
    </div>

    <div>
      <form action="/api/registEndpoint" method="post" target="_blank">
        <input type="hidden" name="endpoint" id="endpoint" />
        <button type="submit">push送信画面を開く</button>
        ※EndPointの情報をサーバに送信後、メッセージ入力画面を開く
      </form>
    </div>
  </div>
</body>

<script src="./index.js"></script>
</html>

index.js (ServiceWorkerの登録を行い、Pushメッセージ受け取るための画面)

window.addEventListener('load', async (event) => {
  // 各種イベントハンドラを登録
  // 「インストール」ボタンをクリック→registerServiceWorker()を実行
  document
    .getElementById('install_svcworker')
    .addEventListener('click', registerServiceWorker);

  // 「インストール」ボタンをクリック→unregisterServiceWorker()を実行
  document
    .getElementById('uninstall_svcworker')
    .addEventListener('click', unregisterServiceWorker);

  // ページロード時にService Workerの登録状況をチェックする
  await checkServiceWorkerRegistered();
});

/**
 * 公開鍵(base64)をUint8Arrayに変換する(subscribeの引数用)
 * @param {*} base64String
 * @returns
 */
const urlB64ToUint8Array = (base64String) => {
  const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
  const base64 = (base64String + padding)
    .replace(/\-/g, '+')
    .replace(/_/g, '/');

  const rawData = window.atob(base64);
  const outputArray = new Uint8Array(rawData.length);

  for (let i = 0; i < rawData.length; ++i) {
    outputArray[i] = rawData.charCodeAt(i);
  }
  return outputArray;
};

/**
 * Service Workerを登録する
 */
const registerServiceWorker = async () => {
  if ('serviceWorker' in navigator) {
    try {
      const swReg = await navigator.serviceWorker.register('./svc_worker.js');
      console.log('Service Worder is registerd', swReg);

      // サーバから公開キーを取得
      const vapidPublicKey = await (await fetch('/api/vapidPublicKey')).text();

      const options = {
        userVisibleOnly: true,
        applicationServerKey: urlB64ToUint8Array(vapidPublicKey),
      };

      // Push通知を購読する
      const reg = await navigator.serviceWorker.ready;
      await reg.pushManager.subscribe(options);

      // Push通知表示を許可するための確認を表示
      Notification.requestPermission((permission) => {
        console.log(permission); // 'default', 'granted', 'denied'
      });

      // ServiceWorkerの状態を画面に表示する
      await checkServiceWorkerRegistered();
    } catch (err) {
      console.log(`Service Worker registration failed: ${err}`);
      await checkServiceWorkerRegistered();
    }
  }
};

/**
 * Service Workerの登録を解除する
 */
const unregisterServiceWorker = async () => {
  if ('serviceWorker' in navigator) {
    try {
      const swReg = await navigator.serviceWorker.getRegistration();
      const subscription = await swReg.pushManager.getSubscription();
      if (subscription) {
        await subscription.unsubscribe();
      }
      await swReg.unregister();

      await checkServiceWorkerRegistered();
    } catch (err) {
      console.log(err);
    }
  }
};

/**
 * Servie Workerが登録されているかチェック
 * 「登録」「登録解除」ボタンを切り替える
 */
const checkServiceWorkerRegistered = async () => {
  // Servie Workerが登録されているかチェックする
  const swReg = await navigator.serviceWorker.getRegistration();

  const elInst = document.getElementById('install-svcw');
  const elUninst = document.getElementById('uninstall-svcw');
  if (swReg) {
    // 登録されている場合、登録解除ボタンを表示する
    elUninst.removeAttribute('style');
    elInst.setAttribute('style', 'display:none;');

    // Push通知を購読しているか確認する
    const subscription = await swReg.pushManager.getSubscription();
    if (subscription) {
      // 購読している場合、curlでPush通知の疎通確認をするコマンドを表示する
      const jsonSub = JSON.stringify(subscription);
      const cmdArea = document.querySelector('.js-subscription-json');
      cmdArea.textContent = `curl http://localhost:3000/api/curlPushTest -X POST -H "Content-Type: application/json" -d '${jsonSub}'`;

      // push送信画面用にEndPointをセットする。
      //  ⇒Push送信画面を開く際、endpointをサーバに送信する。
      //    サーバはendpointを保存し、そこへPush通知を行う。
      const hidden = document.getElementById('endpoint');
      hidden.value = jsonSub;
    }
  } else {
    // 登録されていない場合、登録ボタンを表示する
    elInst.removeAttribute('style');
    elUninst.setAttribute('style', 'display:none;');
  }
};

svc_worker.js (Push受信を行うためのサービスワーカー)

// pushイベントハンドラを登録
self.addEventListener('push', (event) => {
  // 通知設定が行われているかをチェック
  if (!self.Notification || self.Notification.permission !== 'granted') {
    // 通知設定が行われていなければ何もせず終了
    return;
  }

  // 送信されたデータを取得
  if (event.data) {
    const data = event.data.text();
    console.log(data);

    event.waitUntil(
      self.registration.showNotification('Push Notification', {
        body: data,
      }),
    );
  }
});

// 表示された通知をクリックされた場合に発生するイベント
self.addEventListener('notificationclick', function (event) {
  console.log('[Service Worker] Notification click Received.');

  event.notification.close();
});

sendPush.html (Push送信メッセージ入力して、Webサーバに送信するための画面)

<!doctype html>
<html lang="ja">

<head>
  <meta charset="utf-8">
  <title>Push送信画面</title>
</head>

<body>
  <h1>Push送信画面</h1>
  <form >
    <input type="text" name="message" id="message" value="push送信メッセージ" />
    <button type="button" id="pushButton">Push送信</button>
  </form>
</body>

<script src="./sendPush.js"></script>
</html>
window.addEventListener('load', async (event) => {
  document
    .getElementById('pushButton')
    .addEventListener('click', sendPushMessage);
});

/**
 * Push通知送信ボタン
 *   /api/sendMessage にメッセージを送信する
 * @param {*} event
 */
const sendPushMessage = async (event) => {
  event.preventDefault();
  const message = document.getElementById('message').value;

  // FetchAPIのオプション準備
  const param = {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json; charset=utf-8',
    },

    // リクエストボディ
    body: JSON.stringify({ message }),
  };

  await (await fetch('/api/sendMessage', param)).text();
};

主要ソース(サーバ側)

route/api/webpush.ts (index.htmlから受け取ったPush通知用EndPointの保存と、EndPointへのPush通知を行うApi(express))

import express from 'express';
import webPush from 'web-push';
import vapidKey from '../../vapidKey.json'; // generateKey.tsで生成したキーペア

var router = express.Router();

webPush.setVapidDetails(
  'mailto:hoge@example.com',
  vapidKey.publicKey,
  vapidKey.privateKey,
);

/**
 * ブラウザにキーを公開するためのメソッド
 * (必ずしもapiで公開する必要はない。手順簡略化のため)
 */
router.get('/vapidPublicKey', async (req, res, next) => {
  return res.send(vapidKey.publicKey);
});

/**
 * curlからのPush送信テスト用Api
 *  ・curlからendpointを受け取りpush通知を行う(送信メッセージは固定)
 */
router.post('/curlPushTest', async (req, res, next) => {
  console.log(req.body);

  try {
    const response = await webPush.sendNotification(
      req.body,
      'Web Push通知テスト',
    );

    return res.json({
      statusCode: response.statusCode || -1,
    });
  } catch (err) {
    console.log(err);
    return res.status(500).send(err);
  }
});

// endPoint保存用(Push通知送信先)
let endPoint: string = '';

/**
 * endPointをサーバに保存後するApi
 * (保存後、送信画面へリダイレクト)
 */
router.post('/registEndpoint', async (req, res, next) => {
  console.log(req.body);
  endPoint = req.body['endpoint'];
  res.redirect('/sendPush.html');
});

/**
 * 登録されたendpointへメッセージを送信するApi
 * sendPush.jsから呼び出される
 */
router.post('/sendMessage', async (req, res, next) => {
  console.log(endPoint);
  try {
    const response = await webPush.sendNotification(
      JSON.parse(endPoint),
      req.body['message'],
    );

    return res.json({
      statusCode: response.statusCode || -1,
    });
  } catch (err) {
    console.log(err);
    return res.status(500).send(err);
  }
});

module.exports = router;
export default router;

app.ts

import express from 'express';
import path from 'path';
import cookieParser from 'cookie-parser';
import logger from 'morgan';

import webpushRouter from './routes/api/webpush';

var app = express();

app.use(logger('dev'));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));

app.use('/api', webpushRouter);

// importでもrequire()でも読み込めるように2種類export
module.exports = app;
export default app;

その他ソース(キー生成)

generateKey.ts

import webpush from 'web-push';
console.log(JSON.stringify(webpush.generateVAPIDKeys()));
// '{"publicKey":"BFK...GNY","privateKey":"g0y...7jY"}'
11
13
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
13