プッシュ通知サーバーを構築する

この Codelab では、プッシュ通知サーバーを構築します。サーバーはプッシュ サブスクリプションのリストを管理し、通知を送信します。

クライアント コードはすでに完成しています。この Codelab では、サーバーサイドの機能を使用します。

サンプルアプリをリミックスして新しいタブで表示

埋め込み Glitch アプリからの通知は自動的にブロックされるため、このページではアプリをプレビューできません。代わりに、次のようにします。

  1. [Remix to Edit] をクリックして、プロジェクトを編集可能にします。
  2. サイトをプレビューするには、[アプリを表示] を押します。[ 全画面表示 全画面表示

ライブアプリが新しい Chrome タブで開きます。埋め込まれた Glitch で [View Source] をクリックすると、コードを再度表示できます。

この Codelab を進めながら、このページに埋め込まれた Glitch のコードを変更します。ライブアプリで新しいタブを更新して、変更を確認します。

開始時のアプリとそのコードについて理解する

まず、アプリのクライアント UI を確認します。

新しい Chrome タブの場合:

  1. Ctrl+Shift+J キー(Mac の場合は Command+Option+J キー)を押して DevTools を開きます。 [コンソール] タブをクリックします。

  2. UI のボタンをクリックしてみます(出力については Chrome デベロッパー コンソールを確認してください)。

    • Service Worker の登録では、Glitch プロジェクト URL のスコープに Service Worker を登録します。Service Worker の登録を解除すると、Service Worker が削除されます。push サブスクリプションがアタッチされている場合、push サブスクリプションも無効になります。

    • Subscribe to push は、push サブスクリプションを作成します。これは、Service Worker が登録されていて、VAPID_PUBLIC_KEY 定数がクライアント コードに含まれている場合にのみ利用できるため(詳細は後述します)、まだクリックできません。

    • アクティブな push サブスクリプションがある場合、[Notify current subscription] では、サーバーがエンドポイントに通知を送信するようリクエストします。

    • [すべての定期購入に通知] は、データベース内のすべての定期購入エンドポイントに通知を送信するようサーバーに指示します。

      これらのエンドポイントの一部は非アクティブである可能性があります。サーバーから通知が送信されるまでに、定期購入が消失する可能性は常にあります。

サーバーサイドで起きていることを見ていきましょう。サーバーコードからのメッセージを確認するには、Glitch インターフェース内の Node.js ログを調べます。

  • Glitch アプリで [Tools] ->ログをご覧ください。

    通常は「Listening on port 3000」のようなメッセージが表示されます。

    ライブアプリの UI で [Notify current subscription] または [Notify all subscriptions] をクリックしようとすると、次のメッセージも表示されます。

    TODO: Implement sendNotifications()
    Endpoints to send to:  []
    

では、コードを見ていきましょう。

  • public/index.js には完成したクライアント コードが含まれます。機能検出の実行、Service Worker の登録と登録解除、ユーザーのプッシュ通知への登録の制御を行います。また、新しいサブスクリプションと削除されたサブスクリプションに関する情報もサーバーに送信します。

    このファイルはサーバー機能のみを扱うため、編集は行いません(VAPID_PUBLIC_KEY 定数の入力は除きます)。

  • public/service-worker.js は、プッシュ イベントをキャプチャして通知を表示するシンプルな Service Worker です。

  • /views/index.html にはアプリの UI が含まれています。

  • .env には、Glitch の起動時にアプリサーバーに読み込まれる環境変数が含まれています。通知を送信するための認証の詳細を .env に入力します。

  • server.js は、この Codelab でほとんどの作業を行うファイルです。

    開始用コードはシンプルな Express ウェブサーバーを作成します。コードコメントで TODO: とマークされた 4 つの TODO アイテムがあります。次の操作を行う必要があります。

    この Codelab では、これらの TODO 項目を 1 つずつ扱います。

VAPID の詳細を生成して読み込む

最初の作業項目は、VAPID の詳細を生成して Node.js 環境変数に追加し、新しい値でクライアント コードとサーバーコードを更新することです。

背景

ユーザーが通知に登録する際には、アプリの ID とそのサーバーを信頼する必要があります。また、ユーザーは、受信した通知が、定期購入を設定したのと同じアプリからのものであることを確信できるようにする必要があります。また、通知の内容を誰も読まないようにすることも必要です。

プッシュ通知のセキュリティとプライバシーを確保するプロトコルは、Voluntary Application Server Identification for Web Push(VAPID)と呼ばれています。VAPID は公開鍵暗号を使用して、アプリ、サーバー、サブスクリプションのエンドポイントの ID を検証し、通知コンテンツを暗号化します。

このアプリでは、web-push npm パッケージを使用して VAPID 鍵を生成し、通知を暗号化して送信します。

実装

このステップでは、アプリの VAPID キーのペアを生成し、環境変数に追加します。サーバーで環境変数を読み込み、クライアント コードに定数として公開鍵を追加します。

  1. web-push ライブラリの generateVAPIDKeys 関数を使用して、VAPID キーのペアを作成します。

    server.js で、次のコード行の前後のコメントを削除します。

    server.js

    // Generate VAPID keys (only do this once).
    /*
     * const vapidKeys = webpush.generateVAPIDKeys();
     * console.log(vapidKeys);
     */
    const vapidKeys = webpush.generateVAPIDKeys();
    console.log(vapidKeys);
    
  2. Glitch でアプリを再起動すると、生成されたキーが Glitch インターフェース内の Node.js ログに出力されます(Chrome コンソールには出力されません)。VAPID キーを表示するには、[ツール] ->[ログ] を Glitch インターフェースで確認できます。

    公開鍵と秘密鍵は必ず同じ鍵ペアからコピーしてください。

    Glitch はコードを編集するたびにアプリを再起動します。そのため、生成される最初のキーペアは、その後に続く出力に応じてビューからスクロールアウトする可能性があります。

  3. VAPID キーをコピーして .env に貼り付けます。キーを二重引用符("...")で囲みます。

    VAPID_SUBJECT には「"mailto:test@test.test"」と入力します。

    .env

    # process.env.SECRET
    VAPID_PUBLIC_KEY=
    VAPID_PRIVATE_KEY=
    VAPID_SUBJECT=
    VAPID_PUBLIC_KEY="BN3tWzHp3L3rBh03lGLlLlsq..."
    VAPID_PRIVATE_KEY="I_lM7JMIXRhOk6HN..."
    VAPID_SUBJECT="mailto:test@test.test"
    
  4. VAPID キーは 1 回だけ生成する必要があるため、server.js でこの 2 行のコードを再度コメントアウトします。

    server.js

    // Generate VAPID keys (only do this once).
    /*
    const vapidKeys = webpush.generateVAPIDKeys();
    console.log(vapidKeys);
    */
    const vapidKeys = webpush.generateVAPIDKeys();
    console.log(vapidKeys);
    
  5. server.js で、環境変数から VAPID の詳細を読み込みます。

    server.js

    const vapidDetails = {
      // TODO: Load VAPID details from environment variables.
      publicKey: process.env.VAPID_PUBLIC_KEY,
      privateKey: process.env.VAPID_PRIVATE_KEY,
      subject: process.env.VAPID_SUBJECT
    }
    
  6. 公開鍵をコピーして、クライアント コードにも貼り付けてください。

    public/index.js で、.env ファイルにコピーしたのと同じ値を VAPID_PUBLIC_KEY に入力します。

    public/index.js

    // Copy from .env
    const VAPID_PUBLIC_KEY = '';
    const VAPID_PUBLIC_KEY = 'BN3tWzHp3L3rBh03lGLlLlsq...';
    ````
    
で確認できます。

通知を送信する機能を実装する

背景

このアプリでは、web-push npm パッケージを使用して通知を送信します。

このパッケージは、webpush.sendNotification() が呼び出されたときに通知を自動的に暗号化するため、気にする必要はありません。

web-push は、複数の通知オプションを受け入れます。たとえば、メッセージにヘッダーを添付したり、コンテンツ エンコードを指定したりできます。

この Codelab では、以下のコード行で定義された 2 つのオプションのみを使用します。

let options = {
  TTL: 10000; // Time-to-live. Notifications expire after this.
  vapidDetails: vapidDetails; // VAPID keys from .env
};

TTL(有効期間)オプションは、通知の有効期限タイムアウトを設定します。これにより、関連性がなくなった通知をサーバーがユーザーに送信しないようにできます。

vapidDetails オプションには、環境変数から読み込んだ VAPID キーが含まれます。

実装

server.js で、sendNotifications 関数を次のように変更します。

server.js

function sendNotifications(database, endpoints) {
  // TODO: Implement functionality to send notifications.
  console.log('TODO: Implement sendNotifications()');
  console.log('Endpoints to send to: ', endpoints);
  let notification = JSON.stringify(createNotification());
  let options = {
    TTL: 10000, // Time-to-live. Notifications expire after this.
    vapidDetails: vapidDetails // VAPID keys from .env
  };
  endpoints.map(endpoint => {
    let subscription = database[endpoint];
    webpush.sendNotification(subscription, notification, options);
  });
}

webpush.sendNotification() は Promise を返すため、エラー処理を簡単に追加できます。

server.js で、sendNotifications 関数を再度変更します。

server.js

function sendNotifications(database, endpoints) {
  let notification = JSON.stringify(createNotification());
  let options = {
    TTL: 10000; // Time-to-live. Notifications expire after this.
    vapidDetails: vapidDetails; // VAPID keys from .env
  };
  endpoints.map(endpoint => {
    let subscription = database[endpoint];
    webpush.sendNotification(subscription, notification, options);
    let id = endpoint.substr((endpoint.length - 8), endpoint.length);
    webpush.sendNotification(subscription, notification, options)
    .then(result => {
      console.log(`Endpoint ID: ${id}`);
      console.log(`Result: ${result.statusCode} `);
    })
    .catch(error => {
      console.log(`Endpoint ID: ${id}`);
      console.log(`Error: ${error.body} `);
    });
  });
}

新しい定期購入を処理する

背景

ユーザーがプッシュ通知に登録すると、次のようになります。

  1. ユーザーが [Subscribe to push] をクリックします。

  2. クライアントは、VAPID_PUBLIC_KEY 定数(サーバーの公開 VAPID キー)を使用して、サーバー固有の一意の subscription オブジェクトを生成します。subscription オブジェクトは次のようになります。

       {
         "endpoint": "https://fcm.googleapis.com/fcm/send/cpqAgzGzkzQ:APA9...",
         "expirationTime": null,
         "keys":
         {
           "p256dh": "BNYDjQL9d5PSoeBurHy2e4d4GY0sGJXBN...",
           "auth": "0IyyvUGNJ9RxJc83poo3bA"
         }
       }
    
  3. クライアントが、本文に文字列化された JSON 形式のサブスクリプションを含む POST リクエストを /add-subscription URL に送信します。

  4. サーバーは、文字列化された subscription を POST リクエストの本文から取得し、それを解析して JSON に戻して、サブスクリプション データベースに追加します。

    データベースは、独自のエンドポイントをキーとして使用して、サブスクリプションを保存します。

    {
      "https://fcm...1234": {
        endpoint: "https://fcm...1234",
        expirationTime: ...,
        keys: { ... }
      },
      "https://fcm...abcd": {
        endpoint: "https://fcm...abcd",
        expirationTime: ...,
        keys: { ... }
      },
      "https://fcm...zxcv": {
        endpoint: "https://fcm...zxcv",
        expirationTime: ...,
        keys: { ... }
      },
    }

これで、サーバーで新しい定期購入を利用し、通知を送信できるようになりました。

実装

新しい定期購入のリクエストは、POST URL である /add-subscription ルートに送信されます。server.js にスタブルートハンドラが表示されます。

server.js

app.post('/add-subscription', (request, response) => {
  // TODO: implement handler for /add-subscription
  console.log('TODO: Implement handler for /add-subscription');
  console.log('Request body: ', request.body);
  response.sendStatus(200);
});

実装では、このハンドラは次の条件を満たす必要があります。

  • リクエストの本文から新しいサブスクリプションを取得します。
  • 有効なサブスクリプションのデータベースにアクセスします。
  • 新しいサブスクリプションをアクティブなサブスクリプションのリストに追加します。
で確認できます。

新規の定期購入に対応するには:

  • server.js で、/add-subscription のルートハンドラを次のように変更します。

    server.js

    app.post('/add-subscription', (request, response) => {
      // TODO: implement handler for /add-subscription
      console.log('TODO: Implement handler for /add-subscription');
      console.log('Request body: ', request.body);
      let subscriptions = Object.assign({}, request.session.subscriptions);
      subscriptions[request.body.endpoint] = request.body;
      request.session.subscriptions = subscriptions;
      response.sendStatus(200);
    });

定期購入の解約を処理する

背景

サーバーは、サブスクリプションが非アクティブになったことを常に把握できるとは限りません。たとえば、ブラウザが Service Worker をシャットダウンするとサブスクリプションがワイプされることがあります。

ただし、サーバーはアプリの UI を通じて解約された定期購入を知ることができます。このステップでは、データベースからサブスクリプションを削除する機能を実装します。

これにより、サーバーが存在しないエンドポイントに大量の通知を送信することを回避できます。言うまでもなく、単純なテストアプリではそれほど重要ではありませんが、規模が大きくなると重要になります。

実装

定期購入の解約リクエストは、/remove-subscription POST URL に送信されます。

server.js のスタブルートハンドラは次のようになります。

server.js

app.post('/remove-subscription', (request, response) => {
  // TODO: implement handler for /remove-subscription
  console.log('TODO: Implement handler for /remove-subscription');
  console.log('Request body: ', request.body);
  response.sendStatus(200);
});

実装では、このハンドラは次の条件を満たす必要があります。

  • リクエストの本文から、解約された定期購入のエンドポイントを取得します。
  • 有効なサブスクリプションのデータベースにアクセスします。
  • 解約したサブスクリプションを有効なサブスクリプションのリストから削除します。

クライアントからの POST リクエストの本文には、削除する必要があるエンドポイントが含まれています。

{
  "endpoint": "https://fcm.googleapis.com/fcm/send/cpqAgzGzkzQ:APA9..."
}

定期購入の解約に対応するには:

  • server.js で、/remove-subscription のルートハンドラを次のように変更します。

    server.js

  app.post('/remove-subscription', (request, response) => {
    // TODO: implement handler for /remove-subscription
    console.log('TODO: Implement handler for /remove-subscription');
    console.log('Request body: ', request.body);
    let subscriptions = Object.assign({}, request.session.subscriptions);
    delete subscriptions[request.body.endpoint];
    request.session.subscriptions = subscriptions;
    response.sendStatus(200);
  });