APC 技術ブログ

株式会社エーピーコミュニケーションズの技術ブログです。

株式会社 エーピーコミュニケーションズの技術ブログです。

自社運営のWebアプリをPWA化した際にハマった話(OAuthなど)

はじめに

先進サービス開発事業部の高橋です。主にフロントエンド開発を担当しています。今回は私たちの部署で運営しているソーシャルRSSサービス「NEIGHBORS」をPWA化した際にやったことを書いていこうと思います。

NEIGHBORS | ひとりの興味をみんなの知識に NEIGHBORS | ひとりの興味をみんなの知識に

とはいえ、比較的短期間で実装するという目標を掲げていたということもあり、プッシュ通知みたいなすごくネイティブっぽい機能は実装しておらず、キャッシュコントロールやホームに追加してアプリっぽく振る舞うといった比較的簡易な形で落ち着かせています。なので、この記事ではPWAの実装自体について詳しく記載していくというよりは、それまで動いていた機能がPWAというかServiceWorkerを使うことでうまく動かなくなった点に重きを置いています。

PWAとは

PWA(Progressive Web App)とは、ざっくりいうと、WebアプリケーションでありながらネイティブアプリケーションのようなUXを実現したものです。

PWAを実現するためには以下の三点が必要になります。

  • manifest.json
  • Service Worker
  • HTTPSでの通信

manifest.json

manifest.jsonにはアプリケーションをホームに追加した際の設定を記述することができます。 NEIGHBORSでは以下のように設定しています。

{
  "short_name": "NEIGHBORS",
  "name": "NEIGHBORS.",
  "icons": [{
    "src": "touch/android-icon-192x192.png",
    "sizes": "192x192",
    "type": "image/png"
  }, {
    "src": "touch/android-icon-256x256.png",
    "sizes": "256x256",
    "type": "image/png"
  }, {
    "src": "touch/android-icon-512x512.png",
    "sizes": "512x512",
    "type": "image/png"
  }],
  "start_url": "./?utm_source=pwa",
  "display": "standalone",
  "theme_color": "#e56c1b",
  "background_color": "#e56c1b",
  "description": "NEIGHBORS is an SNS sharing articles you read to your friends."
}

short_name

ホームに追加する際の名称

name

アプリケーションの名称。Androidの場合はスプラッシュ画面に表示されます。

icons

iconsはホーム追加時のアイコンとスプラッシュ画面で使われます。 Chrome for Androidでは公式ガイドラインでは192px X 192pxを推奨しており、このサイズがあれば最低限問題はありませんが(一旦iPhoneは考えずに)、デバイスによってはスプラッシュ時に表示される際に違和感を覚えたので、大きい画面用にもう2サイズ用意しました。

start_url

ホームから起動した時のURLを指定できます。NEIGHBORSではGoogleAnalyticsなどでPWAからの流入を計測できるように./?utm_source=pwaと設定しています。

display

displayはブラウザの表示モードに関する項目です。standaloneを選択すると独立したアプリケーションのような形になります。他にもfullscreen minimal-ui browserとありま3すが、standaloneを選んでおけば大丈夫です。

theme_color

ブラウザのテーマカラーを設定できます。これもAndroidだけでiOSはそもそも設定できません。

background_color

起動時のスプラッシュ画面の背景色を設定できます。iPhoneではそもそも使えません。

その他にも項目がありますが、そちらについて詳しくは以下を参照してください。

ウェブアプリマニフェスト | MDN

iOS(Safari)での対応

上記であえて言及しなかったiOS(Safari)ですが、manifest.jsonで設定した内容が反映されていませんでした。 ですので、以下のようにMetaタグでブラウザの設定が必要です。

<link rel="apple-touch-icon" sizes="120x120" href="/touch/apple-icon-120x120.png" />
<link rel="apple-touch-icon" sizes="144x144" href="/touch/apple-icon-144x144.png" />
<link rel="apple-touch-icon" sizes="152x152" href="/touch/apple-icon-152x152.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/touch/apple-icon-180x180.png" />
<link rel="apple-touch-startup-image" href="/splash/ios-splash-640x1136.png" media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" />
<link rel="apple-touch-startup-image" href="/splash/ios-splash-750x1334.png" media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" />
<link rel="apple-touch-startup-image" href="/splash/ios-splash-1242x2208.png" media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" />
<link rel="apple-touch-startup-image" href="/splash/ios-splash-1125x2436.png" media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" />
<link rel="apple-touch-startup-image" href="/splash/ios-splash-828x1792.png" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" />
<meta name="apple-mobile-web-app-capable" content="yes" />

iOS版のPWAだとスプラッシュ画面は各々のデバイスのサイズにあった画像を用意する必要があり割と手間がかかります。manifest.jsonをとは別にMetaタグの編集をしなければならないのもちょっと面倒です。 iOSがPWAに対応してからまだ日も浅いので仕方ないですが、今後のアップデートで仕様を統一してもらえることを期待しましょう。

Service Worker

PWAを支えるのは、ServiceWorkerという仕組みで、キャッシュコントロールやバックグラウンド処理などを実行するスクリプトで、これによりPush通知やオフライン時での画面表示などを実装できるようになります。

create-react-appを使ってプロジェクトを作成するとregisterServiceWorker.jsというファイルがデフォルトで用意されており、ビルドを実行するとServiceWorkerが作成されます。ServiceWorkerを有効化するためには、アプリケーションからregister()を呼び出す必要があります。

// src/index.js
import { register } from './registerServiceWorker.js';

/* 省略 */

register();

手を加えていない状態のregisterServiceWorker.jsでも静的ファイルのキャッシュをキャッシュしオフラインでも表示にする処理を実装しており、最低限のPWAの機能を満たしていると言えます。

ちなみに、manifest.jsonのiconsが一つでも読み込めない状態になっていたら、ServiceWorkerは有効になりません。

またiOS(Safari)ではバックグラウンド処理を行うことができません(2019/03時点)。ですのでPush通知などのネイティブアプリライクなUXも現時点では実現することができません。こちらも今後のアップデートに期待です。

PWA化で動かなくなった機能

PWA化すると普通に動いていた機能が動かなくなることがあります。NEIGHBORSでは以下の二点で期待した動きをせず、処理の書き換えが必要でした。

  • Facebookログイン
  • アンカーリンクでバックエンドにリクエストかける場合

Facebookログインが動かない

NEIGHBORSでは、FacebookやTwitterのOAuthでアカウントを作成したりログインできるようにしていますが、PWAをホームにインストールした際に、FacebookのOAuthでうまくいかず、かなり困りました。

元々は以下のような手順でFacebookのOAuthを実装していました。

取得したAPIを用いてGraph APIでユーザー情報取得

1. ヘッダで Facebook SDK を初期化

<script>
    window.fbAsyncInit = function() {
      FB.init({
        appId            : xxxxxxxxxxxxxxx,
        autoLogAppEvents : true,
        xfbml            : true,
        version          : 'v2.12'
      });
    };
    (function(d, s, id){
       var js, fjs = d.getElementsByTagName(s)[0];
       if (d.getElementById(id)) {return;}
       js = d.createElement(s); js.id = id;
       js.src = "https://connect.facebook.net/en_US/sdk.js";
       fjs.parentNode.insertBefore(js, fjs);
     }(document, 'script', 'facebook-jssdk'));
  </script>

2. 登録・ログインボタンを作成

3. FB.login() でアクセストークンを取得、必要なデータを取得

window.FB.login(response => {
  if (response.authResponse) {
    // トークンを使用して、名前を取得する
    const fbApiUrl = `https://graph.facebook.com/me?fields=name&access_token=${response.authResponse.accessToken}`
    request.get(fbApiUrl).then(res => {
      if (res && res.ok) {
          // Do signin NEIGHBORS.
      }
    }).catch(err => {
        throw new Error('connection failed');
    });
  }
}, {
  scope: 'public_profile,user_friends,email',
  return_scopes: true
})

4. NEIGHBORSにサインイン / サインアップする。

FacebookログインPWA版

上記の手順だと、認証用のウインドウが別ウインドウで開きますが、認証完了後に親ウインドウにアクセストークンが返って来ず、その後の処理が完了しません。

なのでSDKを使わずに実装し直しました。流れとしては以下の通りです。

1. 認証後にリダイレクトするURLをFacebookデベロッパーで登録する

Facebookログイン > 設定 > 有効なOAuthリダイレクトURI

2. react-routerでリダイレクト用のURLを追加

import React, { Component } from 'react'
import { Provider } from 'react-redux'
import {
  Switch,
  Route
} from 'react-router-dom'
import ReturnFromFB from './ReturnFromFB'
...

class App extends Component {
  ...
  render() {
    return (
      <Provider store={store}>
        <ConnectedRouter history={history}>
          <Switch>
            ...
            <Route exact path='/return_from_fb' component={ReturnFromFB} />
          </Switch>
        </ConnectedRouter>
      </Provider>
    )
  }
}

export default App

3. 認証後にリダイレクトする先での処理を実装する

認証後にリダイレクトすると、アクセストークンをパラメータにつけて渡してくるので、 window.opener.postMessage(message, targetOrigin)で親ウインドウにメッセージを送ります。以下の例ではこのリダイレクトURLをメッセージとしています。

import React, { Component } from 'react';

// PWA用のFBログイン後のトークンなどをアプリ本体に渡すためのコンポーネント
class ReturnFromFB extends Component {
  componentDidMount() {
    // 呼び出し元に対してメッセージ(現在のURLの文字列)をポストする
    window.opener.postMessage(window.location.toString(), window.location.href);
    window.close();
  }
  render() {
    return null;
  }
}

export default ReturnFromFB;

4. 認証ダイアログを開く親ウインドウ側の処置を実装する

まずはログインボタンクリックでwindow.open()で認証ダイアログを開きます。 パラメータはclient_id redirect_uriが必須で後は任意です。

window.addEventListener('message', lisner)で小ウインドウからのメッセージを待ち受けて、lisnerのコールバックでURLを解析してアクセストークンを取得します。 その後、Graph APIにアクセストークンを渡してユーザー情報を取得します。

async loginWithFacebookNoSDK() {
    const queryObj = {
      client_id: XXXXXXXXXXXXXXX,
      display: 'popup',
      scope: 'public_profile,user_friends,email',
      response_type: 'token,granted_scopes',
      auth_type: 'rerequest',
      redirect_uri: `${window.location.origin}/return_from_fb`
    }
    const url = `https://www.facebook.com/v2.12/dialog/oauth/?${this.buildQuery(queryObj)}`;
    const popup = window.open(url, 'Facebook Login');
    const promise = new Promise((resolve) => {
      const listener = (event) => {
        const url = new URL(event.data);
        const params = url.hash.split('&');
        let accessToken = '';
        for (let i = 0, l = params.length; i < l; i++) {
          let s = params[i].split('=');
          if (s[0] === '#access_token') {
            accessToken = s[1];
            break;
          }
        }
        const fbApiUrl = `https://graph.facebook.com/me?fields=name&access_token=${accessToken}`
        request.get(fbApiUrl).then(res => {
          if (res && res.ok) {
            // Do signin NEIGHBORS.
          }
        }).catch(err => {
            throw new Error('connection failed');
        });
        resolve(url);
      };
      // メッセージイベントをハンドリングする。
      window.addEventListener('message', listener);
    });
  }
  // パラメータを組み立てる
  buildQuery(queryObj) {
    let queries = [];
    Object.keys(queryObj).forEach(function(key) {
      queries.push(`${encodeURIComponent(key)}=${encodeURIComponent(queryObj[key])}`);
    });
    return queries.join('&');
  }

5. サインイン / アップ処理

以上のプロセスを経てAndroidは期待通りに動きました。

iOSでのFacebookログイン

iOSの場合、上記に加えさらに手を加えなければなりません。どうやらSafariの場合はmanifestの解釈にバグがあるようです。 若干対処療法な感じもしますが、pwacompatというライブラリを読み込んだ上で、以下のようにハックする必要があります。

<link rel="manifest" href="/manifest.json" />
<link rel="pwa-setup" href="/manifest.json" />
<script async src="https://cdn.jsdelivr.net/npm/pwacompat@2.0.6/pwacompat.min.js" integrity="sha384-GOaSLecPIMCJksN83HLuYf9FToOiQ2Df0+0ntv7ey8zjUHESXhthwvq9hXAZTifA"
crossorigin="anonymous"></script>
<script>
  let ios = !!navigator.platform && /iPhone/.test(navigator.platform);
  if (ios) {
    document.querySelector('link[rel="manifest"]').setAttribute("rel", "no-on-ios");
  }
</script>

なお、Androidの場合はrel="pwa-setup"だとPWAの要件に準拠しないので、それとは別にrel="manifest"のタグも必要になります。

参考

redirecting to Google OAuth flow in progressive web app

Remove the manifest from iOS to allow OAuth redirect to works

アンカーリンクでバックエンドにリクエストかける場合

NEIGHBORSはreact-routerというパッケージを使ってSPAを実装しています。react-routerはフロントエンドでURLのルーティングを設定でき、リフレッシュなしでページ遷移できる仕組みを提供してくれます。基本的にバックエンドにはAjaxでリクエストを投げて、帰ってきたJSONなりXMLなどのデータを元にレンダリングを行うという形になります。

ただ、バックエンドに直接リクエストを投げたい場合もあります。NEIGHBORSの場合は読んだ記事に既読をつけるために、一旦バックエンドの処理を経由したのちにクリックした記事にリダイレクトをかけるという仕組みが存在していました。 なのでこの場合は、別タブでアンカーリンクを開いてバックエンドに処理をお任せするという形にしていました。

ここで問題が発生したのですが、ServiceWorkerが有効になっていると同じオリジンでかつServiceWorkerのスコープの範囲内のURLの場合、サービスワーカー からリクエストを返すという仕組みがあります。 NEIGHBORSではドメイン配下は全てをスコープとしていたので、バックエンドへのリクエストが渡らず、status 200 ok でも 画面真っ白。みたいな状態になりました。

考えられる解決は以下の三点かと思います。

  1. ServiceWorkerのスコープの範囲を変更する
  2. 既読処理のURLのドメインを変更する
  3. 実装方法を見直す

1についてはスコープは一部URLを除外することができないので、全てのURLを変える必要がありちょっとそれは問題がありました。

2が正攻法なのかもしれませんが、やりたいことに対して多少大げさかなと思ったので、今回はパスしました。

ということで、3を採用しました。 何をどう変更したかというと

  • 既読処理は既読処理だけをするように変更
  • Ajaxで既読処理のAPIを叩く
  • ステータスコードが204の場合、既読アイコンをつける
  • 既読処理が成功の場合、別タブで記事ページを開く

という感じです。

最後に

随分とだらだらと書いてしまいましたが、プッシュ通知などよりネイティブライクな機能を実装しなければ、それほどハードルの高いものではないと感じました。特にSPAサイトだとキャッシュとオフライン対応ができていればそれだけでも十分にネイティブっぽい感じにはなるので。 今後、iOSの対応も進んでいくかと思いますので、先取りして対応しておくのも良いかと思います。

あと、NEIGHBORSのTwitterやってます。よければフォローお願いしますm( )m

NEIGHBORS公式Twitterアカウント