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

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

クライアント コードはすでに完成しています。この 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 サブスクリプションも無効になります。

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

    • 有効な push サブスクリプションがある場合、[現在のサブスクリプションを通知する] は、サーバーがエンドポイントに通知を送信するようにリクエストします。

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

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

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

  • Glitch アプリで、[Tools] -> [Logs] をクリックします。

    おそらく「Listening on port 3000」のようなメッセージが表示されるはずです。

    ライブアプリの UI で [現在の定期購入に通知] または [すべての定期購入を通知する] をクリックしても、次のメッセージが表示されます。

    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 の詳細を生成して読み込む

最初の TODO 項目は、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 インターフェースで [Tools] -> [Logs] を選択します。

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

    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. ユーザーが [登録してプッシュ] をクリックします。

  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 に戻し、subscriptions データベースに追加します。

    データベースは、独自のエンドポイントをキーとして使用して定期購入を保存します。

    {
      "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);
  });