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

この 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 が削除されます。プッシュ サブスクリプションが関連付けられている場合は、プッシュ サブスクリプションも無効になります。

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

    • 有効なプッシュ サブスクリプションがある場合、Notify current subscription は、サーバーがエンドポイントに通知を送信するようリクエストします。

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

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

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

  • Glitch アプリで、[ツール] -> [ログ] をクリックします。

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

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

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

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

  • public/index.js には、完成したクライアント コードが含まれています。機能検出、サービス ワーカーの登録と登録解除、プッシュ通知へのユーザーの登録を制御します。また、新規および削除された定期購入に関する情報をサーバーに送信します。

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

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

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

  • .env には、Glitch が起動時にアプリサーバーに読み込む環境変数を格納します。通知を送信するための認証情報を .env に入力します。

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

    開始用コードはシンプルな Express ウェブサーバーを作成します。4 つの TODO 項目があり、コードコメントで 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. サーバーは、POST リクエストの本文から文字列化された subscription を取得し、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);
 
});