JavaScript
HTML5
Chrome
ServiceWorker

ServiceWorker のスコープとページコントロールについて

Chrome 40 から ServiceWorker が使えるようになりました。Opera もバージョン 27 から対応し、Firefox でも現在実装が進められています (参考「Is ServiceWorker ready?」)。ServiceWorker はページのライフタイムとは独立した JavaScript の実行コンテキストを提供する機能で、ページからのリクエストをフックしてキャッシュからレスポンスを返したり、サーバからのプッシュイベントを受けてそれをページに通知するといったことが可能になります。

ServiceWorker のコンセプトや基本的な使い方、ユースケースなどは下記のページが参考になります。

ServiceWorker を試用した人のフィードバックを見ていると、どうやら ServiceWorker のスコープの指定を間違えていたり、ServiceWorker がページをコントロールし始めるタイミングを勘違いしていることによってうまく使えていないケースがあるようです。そこで本記事では、上記のページを一通り読んでサンプルコードを眺めたり動かしたりしたことがある人を対象に、スコープやページコントロールについて深く説明していきたいと思います。

DISCLAIMER: ここで述べられている内容はすべて私の個人的な意見に基づくものであり、所属する組織、団体とは一切関係ありません。

ServiceWorker のスコープについて

スコープ (scope) とは ServiceWorker がサービスを提供するページ URL の範囲です。このスコープに含まれるページは ServiceWorker からオフラインアクセスやプッシュ通知といったサービスを受けることができます。一方、スコープに含まれないページはサービスを受けることができません。

例えば、ServiceWorker の提供するサービスの一つにネットワークリクエストのフックがあります。これはスコープ内にあるページを開くメインリソースリクエストと、そのページからのサブリソースリクエスト (eg. XHR) をフックし onfetch イベントハンドラを発火します。

時々「スコープ内の URL に対するサブリソースリクエストが onfetch でフックできない」という質問を受けますが、これはリクエスト元のページがこのスコープに含まれていないことが原因であることが多いです。スコープが意味するのは、フック可能なリクエスト先の URL の範囲ではなく、フック可能なリクエスト元の URL の範囲であることがとても重要です。

image

例えば、/in-scope/ をスコープとして登録した場合、/out-of-scope/page.html から /in-scope/foo.png へのリクエストはフックされませんが、/in-scope/page.html のメインリソースリクエストや、そこから /out-of-scope/foo.png や別オリジンである http://example.com/bar.png へのサブリソースリクエストはフックされます。

image

スコープの指定方法

スコープは navigator.serviceWorker.register() の省略可能な第二引数として指定することができます。

/index.html
navigator.serviceWorker.register("/sw.js", {scope: "/scope/"})
  .then(function(registration) {
      // 登録成功!
    });
/sw.js
self.addEventListener("fetch", function(event) {
    // “/scope/” 以下のページからのリクエストがフックされる。
  });

省略した場合は第一引数で指定したスクリプト URL のディレクトリパスが使われます。例えば、/foo/bar/sw.js をスコープ指定せずに登録した場合は /foo/bar/ がスコープとして扱われ、/foo/bar/page.html などからのリクエストがフックされるようになります。

/index.html
navigator.serviceWorker.register("/foo/bar/sw.js")
  .then(function(registration) {
      // 登録成功!
    });
/foo/bar/sw.js
self.addEventListener("fetch", function(event) {
    // “/foo/bar/” 以下のページからのリクエストがフックされる。
  });

スコープとページ URL は最長一致によって判定されます。もし、/in-scope/ というスコープを持つ ServiceWorker-A と /in-scope/foo/ というスコープを持つ ServiceWorker-B がアクティブな場合、ページ /in-scope/foo/bar.html はより長くマッチする ServiceWorker-B にコントロールされます。

スコープのパス制限

スコープの指定にはセキュリティ上いくつかの制約があります (詳しくは「Service worker が拓く mobile web の新しいかたち」の 40 枚目以降を参照)

  • スコープは同一オリジンしか指定できません。
  • スコープはスクリプト URL 以下のパスを指定しなければなりません。例えば、/foo/bar/sw.js を登録する場合は、スコープとして /foo/bar//foo/bar/in-scope/ を指定できますが、/foo/in-scope/ のように包含関係にないパスを指定するとリジェクトされます (参考「Launching ServiceWorker without breaking the web」)。
  • スクリプト URL によるパス制限は Service-Worker-Allowed: ヘッダをサーバ側で指定することで解除することができます (Chrome 42 以降で対応)

ServiceWorker によるページコントロールについて

ある ServiceWorker があるページのネットワークリクエストをフックできる状態を「ServiceWorker はページをコントロールしている」と表現します。またこのとき「ServiceWorker はページのコントローラーである」といいます。

コントロール対象

ServiceWorker はページ (Document) に限らず、iframe や SharedWorker もコントロールします。iframe の場合は生成時に指定した src によってスコープとのマッチングが行われます。つまり親フレームとは個別の ServiceWorker によってコントロールされる可能性があります。iframe 生成時に src を指定しなかった場合の挙動は現在議論中です。

var frame = document.createElement('iframe');
frame.src = "/foo/bar/iframe.html";
document.body.appendChild(frame);  // frame.src とスコープがマッチングされる

SharedWorker はスクリプト URL によってスコープのマッチングが行われます。SharedWorker のコントロールは Chrome ではバージョン 42 以降で利用可能になります (Issue 450515 - Chromium)。

// スクリプト URL とスコープがマッチングされる
var worker = new SharedWorker('/foo/bar/shared-worker.js');

コントロールするタイミング

ServiceWorker がコントローラーになるには、それがアクティブな状態 (onactivate ハンドラが発火した後の状態) である必要があります。ServiceWorker のアクティベートプロセスについては HTML5 Rocks の記事 が参考になります。

さて、ServiceWorker がページをコントロールするかどうかは、そのページを開いた時に判断されます。もし開こうとしているページをスコープに含むアクティブな ServiceWorker がいれば、それがコントローラーになります。これは別の見方をすると、ページを開いた後に登録された ServiceWorker が、そのページを直ち (リロード無し) にコントロールすることは基本的にはないことを意味します。これをコードで表現すると次のようになります。

/scope/will-be-controlled.html
navigator.serviceWorker.register("/sw.js", {scope: "/scope/"})
  .then(function(registration) {
      // 登録成功!
      return navigator.serviceWorker.ready;
    })
  .then(function(registration) {
      // アクティベートされたが、この時点では
      // このページ “/scope/will-be-controlled.html” はコントロールされていない。
      assert_true(navigator.serviceWorker.controller == null);
    });

アクティベート完了時点で will-be-controlled.html は既に開かれているため、リロードするまでそのページからのリクエストがフックされることはありません。

ページ側で自身のコントローラーとなる ServiceWorker がアクティブかどうかは、navigator.serviceWorker.ready をチェックすると分かります。ready は ServiceWorker がアクティブな時に resolve される Promise を返します。注意すべきは ready が resolve されたからといって、そのページがコントロールされているとは限らないことです。

ページがコントロールされているかどうかは navigator.serviceWorker.controller をチェックすると分かります。コントロールされている場合は ServiceWorker オブジェクトが、されていない場合は null が返ってきます。

先ほど “ページを開いた後に登録された ServiceWorker が、そのページを直ち (リロード無し) にコントロールすることは基本的にはない” といいました。しかし、ページのリロードを待たずに ServiceWorker にコントロールさせたい場合も多くありそうです。例えば、テスト環境で ServiceWorker によって HTTP サーバをモックしたい場合、初回ロード時からリクエストをフックできると良さそうです。これを可能にする claim() という API を ServiceWorker は提供します (Chrome ではバージョン 42 以降で使用可能です)。これについては別の記事で改めて説明したいと思っています。

(2015/04/18 補足) claim() について書きました

まとめ

ServiceWorker のスコープとページコントロールについて説明しました。

  • スコープは ServiceWorker がサービスを提供するページの URL の範囲を意味します。
  • スコープ内のコントロールされているページのメインリソースリクエストとそのページが発行したサブリソースリクエストが onfetch ハンドラによってフックされます。
  • ServiceWorker がページをコントロールするかどうかは、そのページを開いた時に判断されます。もし開こうとしているページをスコープに含むアクティブな ServiceWorker がいれば、それがコントローラーになります。
  • ページを開いた後に登録された ServiceWorker が、そのページをリロード無しにコントロールすることは基本的にはありません。これをするには claim() を呼ぶ必要があります。