📖

PWAとAmazon Pinpointによる通知機能

2023/05/04に公開

What

下記の要望を叶えるためにPWA対応とAmazon Pinpointを利用したプッシュ通知・メール送信を実現しました。

  • ネイティブアプリを作成したいけど、リソースの問題で実現が難しい
  • 通知手段としてプッシュ通知を利用したい
  • 運用中の様々な通知サービスを一元管理したい

Why

なぜこのタイミングなのかというと、
iOS 16.4から Web Pushをサポートするようになったからです。厳密にはSafariのみが対応で他のブラウザでは非対応となります。
PWAのメリットとしてWeb Pushがありますが、長らくAndroidのみのサポートでした。iOSのシェアが多い日本においては、PWAが流行しない大きな要因となっていたと思われます。

How

今回採用したアーキテクチャはAWSさんのブログを参考にさせていただきました。
AWSリソースは運用上Terraformで構築することが望ましかったので、AWS Amplifyの機能を利用した構築は行いませんでした。

学習事項

実装に入る前に以下の項目について学習することを強くお勧めします。

PWA

こちらからPWAについて順を追って学習することができます。
WebサイトをPWA対応する条件については、こちらに記載があります。
PWAの対応状況については、Lighthouseを使って確認できるので是非ご利用ください。

Service Worker

概要です。
Service Workerとはブラウザのバックグラウンドで動作するプロセスです。
サーバサイドからのレスポンスをインターセプトしてブラウザへ応答します。

Service workers are a fundamental part of a PWA. They enable fast loading (regardless of the network), offline access, push notifications, and other capabilities.

Cache Storage APIを用いたロード時間の短縮、ネイティブアプリにあるようなオフライン時の制御、Push notifications APIを利用したデバイスへの通知表示などを実現できます。
最も抑えておきたいポイントはライフライクルです。Service Workerのイベントに合わせてキャッシュ、オフラインページ、プッシュ通知を制御する必要があります。

プッシュ通知

Webプッシュを実現する機能としてはPush API と Notification APIの2つがあります。
https://web.dev/notifications/

Firebase Cloud Messaging

デバイスで一意に定まるFCMトークンを管理するシステムです。
今回のアーキテクチャでは、通知機能のプロキシ的な立ち位置になります。
https://firebase.google.com/docs/cloud-messaging?hl=ja

実装編

下記環境での開発を前提とします。

  • React
  • Next.js
  • TypeScript
  • Terraform(AWS)

フロントエンド

npm

npm i aws-amplify firebase workbox-webpack-plugin

web app manifest

インストール可能なアプリケーションとしての設定をweb app manifestに記載する必要があります。
名前、アイコン、開始位置のURL、ディスプレイモードなどを記載する。各プロパティについてはこちらに説明があります。

manifest.js
{
  "short_name": "short name",
  "name": "name", //アイコンの名前
  "icons": [
    {
      "src": "/images/icons-192.png",
      "type": "image/png",
      "sizes": "192x192"
    },
    {
      "src": "/images/icons-512.png",
      "type": "image/png",
      "sizes": "512x512"
    }
  ], //192x192、512x512ピクセルの画像を参照
  "start_url": "/", //開始URL
  "background_color": "#FFFFFF",//背景色
  "display": "standalone", //ディスプレイモード
  "theme_color": "#FFFFFF", //テーマカラー
  "crossorigin": "use-credentials",
  "orientation": "any", //既定の向き
}

FCM

Firebase Cloudにアクセスし、新規プロジェクトを作成し以下を設定します。

  • FCMの有効化
    ※ Amazon Pinpointのプッシュ通知の有効化で利用します
  • Vapid Keyを作成
    ※ アプリケーションでFCMトークンを作成する際に利用します
  • JavaScriptを有効化

Service Worker

Service Workerの実装

serviceWorker.js
self.addEventListener("install", (event) => {
  // キャッシュへリソースを保存
});
self.addEventListener("push", (event) => {
  // push通知を受け取って、Notifications APIを実行してデバイスに表示する
});
self.addEventListener("notificationclick", (event) => {
  // push通知の中にuriが含まれる場合に指定のサイトに移動する
});
self.addEventListener("activate", (event) => {
});
addEventListener("fetch", (event) => {
  // キャッシュ、オフライン時の制御
});

Service Workerが有効なブラウザであるかを確認し、ユーザの許可を受けて有効化する。

service-worker.ts
import { ServiceWorker } from "@aws-amplify/core";
import { isSupported } from "firebase/messaging";

const appPublicKey = <FCMで取得できるvapid key>;

export const registerServiceWorker = async (): Promise<void> => {
  const isSupport = await isSupported();
  if (isSupport) {
    const serviceWorker = new ServiceWorker();
    try {
      await serviceWorker.register("/sw.js", "/");
permission
      const permission: NotificationPermission = await Notification.requestPermission();
      if (permission === "granted") {
        await serviceWorker.enablePush(appPublicKey);
      }
    } catch (e: unknown) {
      // エラーハンドリング
    }
  }
};

登録済みのService Workerを利用して、FCMトークンを取得する。

firebase.ts
import { getMessaging, getToken } from "firebase/messaging";

export const getFCMToken = async (): Promise<string> => {
  try {
    const registration = await navigator.serviceWorker.getRegistration();
    if (registration === undefined || !registration.active) {
      throw new Error();
    }
    const appPublicKey = <FCMで取得できるvapid key>;

    return await getToken(
      getMessaging(),
      { 
        vapidKey: appPublicKey,
	serviceWorkerRegistration: registration 
      }
    );
  } catch (e: unknown) {
    // エラーハンドリング
  }
};

Amazon Pinpoint

Update Endpoint APIを利用して通知情報を登録する。

pinpoint.ts
import { Analytics } from "@aws-amplify/analytics";
import { ChannelType } from "@aws-sdk/client-pinpoint";

export const updateEndpoint = async (
  address: string, // エンドポイント
  channelType: ChannelType // プッシュ通知はFCM、アドレスはEMAILを指定
): Promise<void> => {
  await Analytics.updateEndpoint({
    address,
    channelType: channelType.valueOf(),
    optOut: "NONE",
  });
};

ハマりポイント

  • iOS対応の厄介さ
    Androidの場合はmanifestを作成するだけでPWA化が完了するが、iOSの場合は,各種linkやmetaタグ情報を付与する必要がある。
    特に面倒なのがスプラッシュスクリーンを表示するために、各種デバイスのピクセル数に合わせた画像を用意しなければいけないので、実装・運用コストがかかる。
    解決策として、pwacompatがある。manifestの情報を元にデバイスに合わせたアイコン、スプラッシュ画像などのメタ情報を付与してくれるので便利である。しかし、次に説明する理由からデフォルトのスプラッシュスクリーン機能を利用しないめ、こちらの機能は利用を見送った。
  • スプラッシュ画像のタイトルを消せない
    PWAで提供されているデフォルトのスプラッシュスクリーンの機能を使うと、manifestのnameタグがタイトルとして入ってしまう。削除したい場合はトップ画面を作成するのが手っ取り早い。
top.html
<!DOCTYPE html>
<html>
  <body>
    <img
      img
      src="/example.png"
    />
    <script>
      document.onreadystatechange = function (event) {
        if (document.readyState == "complete") {
          window.location.href = <遷移先>;
        }
      };
    </script>
  </body>
</html>
  • Update Endpoint APIのUserAttributesがList型しか受け付けない
    List型で指定しないとエラーになる。
    公式のレファレンスをみても、UserAttributesはList<String>を指定すると記載がある。
  • ローカルストレージで単一のキーしか扱えない
    aws-amplifyではローカルストレージにPinpointのエンドポイントを管理しますが、1つのみの管理となります。プッシュ通知とメール送信の2つを実現したい場合は、ローカルストレージの管理を実装する必要があります。
    Web Storage APIを利用してlocalStorageにエンドポイント情報を保存、取得する処理を行います。

インフラ(Terraform)

Amazon Pinpoint

pinpoint_app sample

pinpoint.hcl
locals {
  pinpoint_email_channel_from_address = <FROMのメールアドレス>
  pinpoint_ses_domain_identity        = <SESで認証されるドメイン>
}

resource "aws_pinpoint_app" "example" {
  name = "test-app"
}

resource "aws_pinpoint_email_channel" "email" {
  application_id = aws_pinpoint_app.notify.application_id
  from_address   = local.pinpoint_email_channel_from_address
  identity       = aws_ses_domain_identity.torana_email_domain_identity.arn
}

resource "aws_ses_domain_identity" "torana_email_domain_identity" {
  domain = local.pinpoint_ses_domain_identity
}

resource "aws_pinpoint_gcm_channel" "gcm" {
  application_id = aws_pinpoint_app.notify.application_id
  api_key        = <FCMで生成されたAPI KEY>
}

Amazon Cognito ID Pool

cognito_identity_provider sample

cognitoIdentifyPool.hcl
resource "aws_cognito_identity_pool" "idp" {
  identity_pool_name               = "idp"
  allow_unauthenticated_identities = true
}

data "aws_iam_policy_document" "authenticated" {
  statement {
    effect = "Allow"

    principals {
      type        = "Federated"
      identifiers = ["cognito-identity.amazonaws.com"]
    }

    actions = ["sts:AssumeRoleWithWebIdentity"]

    condition {
      test     = "StringEquals"
      variable = "cognito-identity.amazonaws.com:aud"
      values   = [aws_cognito_identity_pool.idp.id]
    }

    condition {
      test     = "ForAnyValue:StringLike"
      variable = "cognito-identity.amazonaws.com:amr"
      values   = ["authenticated"]
    }
  }
}

resource "aws_iam_role" "authenticated_iam_role" {
  name = "authenticated_iam_role"

  assume_role_policy = data.aws_iam_policy_document.authenticated.json
}

data "aws_iam_policy_document" "unauthenticated" {
  statement {
    effect = "Allow"

    principals {
      type        = "Federated"
      identifiers = ["cognito-identity.amazonaws.com"]
    }

    actions = ["sts:AssumeRoleWithWebIdentity"]

    condition {
      test     = "StringEquals"
      variable = "cognito-identity.amazonaws.com:aud"
      values   = [aws_cognito_identity_pool.idp.id]
    }

    condition {
      test     = "ForAnyValue:StringLike"
      variable = "cognito-identity.amazonaws.com:amr"
      values   = ["unauthenticated"]
    }
  }
}

resource "aws_iam_role" "unauthenticated_iam_role" {
  name = "authenticated_iam_role"

  assume_role_policy = data.aws_iam_policy_document.unauthenticated.json
}

data "aws_iam_policy_document" "pinpoint_role_policy" {
  statement {
    effect = "Allow"

    actions = [
      "mobiletargeting:PutEvents",
      "mobiletargeting:UpdateEndpoint"
    ]

    resources = [
      "${aws_pinpoint_app.example.arn}/endopint/*",
      "${aws_pinpoint_app.example.arn}/events",
    ]
  }

  depends_on = [aws_pinpoint_app.example]
}

resource "aws_iam_role_policy" "authenticated" {
  name   = "authenticated-iam-role"
  role   = aws_iam_role.authenticated_iam_role.role.id
  policy = data.aws_iam_policy_document.pinpoint_role_policy.json
}

resource "aws_iam_role_policy" "unauthenticated" {
  name   = "unauthenticated-iam-role"
  role   = aws_iam_role.unauthenticated_iam_role.role.id
  policy = data.aws_iam_policy_document.pinpoint_role_policy.json
}

resource "aws_cognito_identity_pool_roles_attachment" "idp" {
  identity_pool_id = aws_cognito_identity_pool.idp.id

  roles = {
    "authenticated"   = aws_iam_role.authenticated_iam_role.role.arn
    "unauthenticated" = aws_iam_role.unauthenticated_iam_role.role.arn
  }
}

その他の検討事項

Next.js環境であればnext-pwaを使うことでpwa化をスムーズに行うことができます。今回はプッシュ通知も合わせて実現する都合上でデフォルトで用意される、sw.jsを利用できなかったので採用を見送りましたが、PWA化が主の目的であれば迷わず採用するのが良いでしょう。

Torana tech blog

Discussion