Nuxt × PWA × CDNでハマった件について

この記事は[パーソルキャリア Advent Calendar 2018](https://qiita.com/advent-calendar/2018/pca)の2日目です。
Nuxt.jsを使ってPWAを作る際にCDNを使おうとしてハマったのでそのことについて書いていきます。

## この記事の内容
- Nuxt.jsにおけるCDNの利用
- Service Workerとクロスオリジン
- Opaque Responseとその取扱い
- manifest.jsonとクロスオリジン


## この記事で触れないこと
- Nuxt.jsとは何か
- PWAとは何か

Nuxtについては公式ドキュメントがわかりやすいので[こちら](https://ja.nuxtjs.org/)をご参照ください。
PWAについては、[Googleのコードラボ](https://codelabs.developers.google.com/codelabs/your-first-pwapp-ja/#0)や[私自身が簡単にまとめたスライド](https://speakerdeck.com/gyarasu)をご参照いただければと思います。

## tl;dr
- Service Workerからクロスオリジンのリクエストができないので、静的アセットをCDNに置いてService Workerでキャッシュする場合CDN側で`Access-Control-Allow-Origin`を設定してあげる
- `manifest.json`（マニフェストファイル）は原則として同一オリジンに置く必要があるのでCDNから読まないようにする

## Nuxt.jsにおけるCDNの利用
NuxtでCDNを利用する場合は、`nuxt.config.js`（設定ファイル）の`build.publicPath`に対応するCDNのURLを指定します。こうすることによって、ビルド時に`.nuxt/dist/client`の内容がCDNにアップロードされます。

```javascript:nuxt.config.js
export default {
  build: {
    publicPath: 'https://cdn.nuxtjs.org'
  }
}
```
詳しくは[こちら](https://ja.nuxtjs.org/api/configuration-build/#publicpath)。

※`https://cdn.nuxtjs.org/`はサンプルなので、適宜ご自身の利用するものを想定して読み替えてください。

これだけだでは何ということもなく、ページを表示した際にjavascriptのファイルが普通にCDNから読まれるようになります。問題となるのはPWA化してService Workerを稼動させた場合です。


## Service Workerとクロスオリジン
Nuxt.jsをPWA化したい場合、[pwa-module](https://github.com/nuxt-community/pwa-module)を使うととても簡単です。`nuxt.config.js`の`modules`プロパティに追加して、設定を少し書くだけでホーム画面への追加やService Workerによるキャッシュができるようになります。

> NuxtでCDNを利用する場合は、`nuxt.config.js`（設定ファイル）の`build.publicPath`に対応するCDNのURLを指定します。こうすることによって、ビルド時に`.nuxt/dist/client`の内容がCDNにアップロードされます。

と述べましたが、`pwa-module`を追加してからビルドすると、Service Workerのスクリプトが自動生成されます。`npm run build`したあとに`static/sw.js`に出力されます。

```javascript:sw.js
importScripts('https://cdn.nuxtjs.org/workbox.3rm34239g.js')

workbox.precaching.precacheAndRoute([
  {
    "url": "https://cdn.nuxtjs.org/0392146f2d6284.js",
    "revision": "028b52e95a28bb08c34ad092c99422ab8f"
  },
  // 以下、ビルドされたファイルが続きます
], {
  "cacheId": "sample",
  "directoryIndex": "/",
  "cleanUrls": false
})
```

この記事では`preCache`について述べます。`publicPath`を設定すると、対象のCDNに`.nuxt/dist/client`の中身がアップロードされますが、Service Workerの`preCache`対象のファイルとしても登録されます。

`preCache`では、アプリケーションの静的アセットが最初のページ読み込み時にキャッシュされます。PWAにおける`プログレッシブ・エンハンスメント`の概念で言うと、アプリケーションの骨格である`AppShell`がキャッシュされると考えても良いでしょう。

この状態でいざページを読み込んでブラウザのコンソールを見ると下記のようなエラーが出ます（CDN側の設定を特に何もしていない場合）。

> Access to fetch at 'https://cdn.nuxtjs.org/f9fbee91a383e90d0.js' from origin 'https://your.domain' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.

見慣れてる人も多いかもしれませんが、「クロスオリジンのアクセスするならサーバ側で`Access-Control-Allow-Origin`指定してあげてね。opaque responseでいいならフェッチするときにmodeを`no-cors`で指定してね」という旨のエラーです。

## なんでPWA化した途端にCORSのエラーが出るの？
「何もしてないのにエラーが出た」まではいきませんが、「なんでダメなの？」となってしまいます。
状況を整理すると次の通りです。

- PWA化する前はエラーが出なかった
- Service Workerによる`preCache`を追加するとエラーになった

ページを見てみるとわかると思いますが、正しく表示されているはずです。これはHTMLに埋め込んでいる`<script>`タグからはうまく参照できているということです。ということは、Service Workerでキャッシュしようとしたときにリクエストがエラーになっているということになります。

### HTML5における`cross-orgin`の仕様
原点に立ち返ると、このエラーは特殊でもなんでもなく、ごく普通の内容です。

- 通常、サーバ側の対応がないとクロスオリジンのリクエストは通らない
- Service Workerのリクエストがコケるのは当たり前

これらを意識すると「なんで`<script src="https://cdn.nuxtjs.org/f9fbee91a383e90d0.js">`はうまくいくんだ？」という話になります。

[MDN](https://developer.mozilla.org/ja/docs/Web/HTML/CORS_settings_attributes)を見ると次のように記述されています。

> HTML5 では、 CORS への対応が `<img>`, `<video>`, `<script>` など一部の HTML 要素で行われ、 crossorigin 属性 (crossOrigin プロパティ) で、要素が取得するデータに関する CORS リクエストを構成することができます。

また、[こちらのサイト](https://fullstack-developer.academy/what-is-an-opaque-response/)には次にように記述されていました。

> The browser does allow us to access cross-origin resources for a subset of elements including `<script>`, `<img>`, `<video>`, `<audio>`, `<link rel="stylesheet">`, `<object>`, `<embed>` and `<iframe>`.

というわけで、（特定の）HTMLタグで埋め込んであればOKで、Javascriptでリクエストを投げるとNGということで状況が整理できます。
試しに、ブラウザのコンソールに`fetch('https://cdn.nuxtjs.org/f9fbee91a383e90d0.js')`という感じでクロスオリジンへのリクエストを打ち込んで実行してみると、うまくいかないことが確認できます。

## Service Workerでクロスオリジンのリソースをキャッシュするには
実は、どのように対処すれば良いかはエラーメッセージが親切に教えてくれています。
すでに述べたとおりですが、対処方法は次の2通りあります。

1. CDNからのレスポンスヘッダに`Access-Control-Allow-Origin`をつける
2. Javascriptでフェッチするときに`mode: 'no-cors'`を指定する

**結論としては何のへんてつもないのですが、1の対応をする**のが一番良いと考えます。
CDNもAmazon CloudFrontやGoogle Cloud CDN等色々あると思いますが、管理画面から設定できるかと思います（当方はAkamaiで試しました）。

結論は出たのですが、じゃあ2番目の対処方法はなんなんだよってことなので、ちょっと脇道にそれますが補足します。

## Opaque Responseとその取扱い
試しにブラウザのコンソールに下記のようなクロスオリジンのリソースをフェッチするスクリプトを打ち込んでみます。

```javascript
const res = await fetch('https://cdn.nuxtjs.org/f9fbee91a383e90d0.js', {mode: 'no-cors'});
```
こんな感じで、fetch関数の第2引数で`no-cors`を指定してあげると、クロスオリジンのアクセスでもエラーが出なくなります。
ではレスポンスはどうなっているでしょうか。

![スクリーンショット 2018-11-30 15.36.11.png](https://qiita-image-store.s3.amazonaws.com/0/29971/c67581c2-d6b6-932f-9a3f-edb90a7a1008.png)

`console.log`でレスポンスを見てみるとこんな感じになってます。
これがOpaque Response！

- bodyがnull
- typeがopaque
- statusが0

特徴的なのはといたっところでしょうか。

### Opaque Responseとはなにか
直訳すると「不透明なレスポンス」となりますが、定義を見てみましょう。

[MDN](opaque: Response for “no-cors” request to cross-origin resource.)には次のようにあります。
> opaque: Response for “no-cors” request to cross-origin resource. Severely restricted.

もうちょっと掘り下げて[仕様書](https://fetch.spec.whatwg.org/#concept-filtered-response-opaque)を見ると、
> An opaque filtered response is a filtered response whose type is "opaque", URL list is the empty list, status is 0, status message is the empty byte sequence, header list is empty, body is null, and trailer is empty.

とありました。
文中に出てくる`filtered response`は次の通り。

> A filtered response is a limited view on a response that is not a network error. This response is referred to as the filtered response’s associated internal response.

要するに、`no-cors`を指定してクロスオリジンにリクエストした結果得られるレスポンスであり、その中身はフィルタリングされていて見えないものとざっくり解釈できます。


### Opaque ResponseはService Workerでキャッシュできるか？
結論としては**できます**。
[Workboxのドキュメント](https://developers.google.com/web/tools/workbox/guides/handle-third-party-requests)を見ると、Opaque Responseを強制的にキャッシュする方法が書かれています。
ただ、注意が必要です。

> Warning: This will cache a response that could be an error, which would then never get updated!

とあるように、Opaque　Responseはステータスコードが0なので成功してるか失敗してるか（正しいデータがとれてるのかどうか）わかりません。つまり、実は失敗してたレスポンスをキャッシュしてしまう可能性があります。

なので、よほどの理由がない限りOpaque Responseはキャッシュしないほうが良いと思います。


Service Workerとクロスオリジン、Opaque Responseについては下記を参考にさせていただきました。

- http://azu.github.io/slide/pixelgrid/serviceworker-cors.html
- https://fullstack-developer.academy/what-is-an-opaque-response/

## manifest.jsonとクロスオリジン
無事にAppShellがキャッシュされるようになって一安心、となったのですが、また別のエラーが発生していました。
`manifest.json`が読み込まれないというものです。

`nuxt.config.js`の`build.publicPath`でCDNを指定すると、`manifest.json`もCDNを参照するようになります。
`manifest.json`はPWAにおいてとても大事なファイルで、ホーム画面に追加する際のアイコンを指定したり、アプリ名やテーマカラー、表示モードなどの各種設定を記述します。

少し古いですが[githubのissue](https://github.com/w3c/manifest/issues/360#issuecomment-91661289)を見たところ、manifestファイルは同一オリジンであることが求められるようです。
（クロスオリジンから読める方法知ってるよ〜という方がいらっしゃいましたら是非ご指摘いただければと思いますmm）

`pwa-module`にはmanifestモジュールが含まれていて、各種設定が行えます。少なくとも[日本語ドキュメント](https://pwa.nuxtjs.org/modules/manifest)にはほとんど情報がありませんが、実はCDNを使っていてもmanifestファイルを同一オリジンで参照できます。manifestモジュールにも`publicPath`属性が存在するので指定すればOKです。

```javascrit:nuxt.config.js
      modules: [
        [
          '@nuxtjs/pwa',
          {
            manifest: {
              publicPath:  '/_nuxt/',
            },
          },
        ],
      ],
```
`/_nuxt/`はビルドファイルが出力されるデフォルトのパスです。
何も指定しない場合は最初に指定した`build.publicPath`と同じものが入ります。
気になる方は[ソースコード](https://github.com/nuxt-community/pwa-module/blob/dev/packages/manifest/index.js#L74)をご確認ください。

## まとめ
Nuxtで作ったWebアプリををPWA化してかつCDNを使うにあたって、多くの学びを得られました。
本記事が同じような構成でPWAに挑戦する方のお役に立てば幸いです。
AppShellは最初のアクセスでキャッシュされるのにわざわざCDN使うメリットはそんなにないような気もしますが、ご参考まで。