いしぐめも

プログラミングとかしたことを書きます。

SPAのURLどうなってるの問題とPersonium

はじめに

みなさん、SPA(Single Page Application)作ってますでしょうか?

SPAとは

従来はWebサイトでURLがリクエストされたときに、リクエストされたURLに応じてサーバーサイドでHTMLを生成し、返すような方法がとられていました。 それに対し、SPAではWebサイトを1つのHTMLとJavaScriptレンダリングする方法がとられています。

1つのHTMLとJavaScript でサイトのコンテンツを表示することから「シングル・ページ・アプリケーション」と呼ばれています。

メリットとしては

  • 提供するコンテンツが1種類のため、配信が楽になる
  • 1つの操作が部分更新で終わるためサクサク動く

などでしょうか。(詳細は詳しいサイトを読んでください。

SPAパスどうなってるの問題

先ほど「最近ではWebサイトを1つのHTMLとJavaScriptレンダリングする」と書きましたが、 少しWebアプリのURLを見たことがある人はわかるかもしれませんが、このように、表示される内容によってパス(/home/notifications/yoh1496/status/1387330303048372224)が違います。

これらは、URLを他の人に共有した時や、リロードしたとき、戻るボタンを押したときなど、いちいちアプリのトップ画面に戻ってたらユーザーにとって不便極まりないので最高にうれしい機能なんです。

では、Webサーバーに /yoh1496/status/1387330303048372224 といったパスがリクエストされたとき、どうなっているのか?というと、当然 /yoh1496/status/1387330303048372224 という名前のhtmlファイルがあるわけではありません。動的にファイルを解決しています。

nginxの場合

nginxには try_filesディレクティブ というものがあります。

http://nginx.org/en/docs/http/ngx_http_core_module.html#try_files

try_files $uri $uri.html index.html

try_files は左から順番にパスを確認し、マッチするパスがあれば返すといった設定をすることができます。例えば、aaa/iii というパスが来た時、上記例では

  • aaa/iii
  • aaa/iii.html
  • index.html

というパスを確認しに行きます。 aaa/iiiaaa/iii.html も存在しなければ index.html が返る、という仕組みです。

これを使うことで、任意のパスに対して決まったHTMLファイルを返すことができるのです。

Firebaseの場合

Firebase の hosting を使ってSPAをホスティングする場合の設定はコチラ。

https://firebase.google.com/docs/hosting/full-config?hl=ja#rewrites

"hosting": {
  // ...

  // Serves index.html for requests to files or directories that do not exist
  "rewrites": [ {
    "source": "**",
    "destination": "/index.html"
  } ]
}

firebase.json に上記の設定を記載することで、リライトする(リダイレクトとは違い、返す内容だけを変える)することができます。

webback-dev-serverを使う場合

SPAの開発でおなじみ、webpack-dev-server でも実はそういう設定を行っています。

https://webpack.js.org/configuration/dev-server/#devserverhistoryapifallback

module.exports = {
  //...
  devServer: {
    historyApiFallback: {
      rewrites: [
        { from: /^\/$/, to: '/views/landing.html' },
        { from: /^\/subpage/, to: '/views/subpage.html' },
        { from: /./, to: '/views/404.html' },
      ],
    },
  },
};

こうすることで、 from の正規表現とマッチするパスに対してリライトを設定することができます。設定をコピペしていると忘れがちですが、リライトやフォールバックってとっても大切なのです。

PersoniumとSPA

Personiumは基本的にWebDAVというインタフェースに則っているので、 それぞれのURLが1ファイル・1フォルダに対応しています。

Personiumでは、そのWebDAVに「Service Collection」という拡張を施し、特定のフォルダ配下のURLに対してJavaScriptで実装したサーバーサイドロジックを紐づけ、 HTTPリクエストに対してそのロジックの結果を返せるようになっています。

Service Collection には、ロジックを関数の形で書き、リクエストに対して返したい内容をreturnします。

function(request) {
  return {
        status: 200,
        headers: {"Content-Type":"text/plain"},
        body: ["Hello World !!"]
  };
}

この7行で Hello World !! と返すロジックを組むことができます。これを特定のURLにバインドするには下記のようなXMLを書き、 PROPPATCH します。

<D:propertyupdate xmlns:D="DAV:"
    xmlns:p="urn:x-personium:xmlns">
  <D:set>
    <D:prop>
      <p:service language="JavaScript">
        <p:path name="say_hello" src="hello.js"/>
      </p:service>
    </D:prop>
  </D:set>
</D:propertyupdate>

これを例えば、 https://app.pds.example.com/__/script という Service Collection に適用すると以下のようなURLで使用可能になります。

f:id:yoh1496:20210614172820p:plain
登録したロジックのURL

Version 1.7.21 まで

個人的に、SPAをやるのに https://app.pds.example.com/__/public/index.html のような、拡張子 html がついたURLになるのが嫌だったので、こんなロジックを組みました。

function launch(request) {
  const tempHeaders = { 'Content-Type': 'text/html' };

  return {
    status: 200,
    headers: tempHeaders,
    body: [
      [
        '<!DOCTYPE html>',
        '<html lang="en">',
        '<head>',
        '<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500">',
        '</link>',
        '<meta charset="utf-8">',
        '<meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no">',
        '<title>Personium App</title>',
        '</head>',
        '<body style="margin: 0px" >',
        '<noscript>You need to enable JavaScript to run this app.</noscript>',
        '<div id="root"></div>',
        '<script type="text/javascript" src="/__/public/main.bundle.js">',
        '</script>',
        '</body>',
        '</html>',
      ].join('\n'),
    ],
  };
}

これを https://app.pds.example.com/__/front という Service Collection に app という名前でエンドポイントを設定します。こうすることで、以下のようなURLにアクセスしたときにindex.htmlと同等な内容を返し、 /__/public/main.bundle.js に配置したJavaScriptでSPAを実行できるという寸法です。

f:id:yoh1496:20210614173324p:plain
一見それっぽいURL

しかし、このスクリプトが紐づけられているパスは app 1つであるため、アプリ内のパスをURLのpathで表現しようとすると(たとえば、/__/front/app/home など)、たちまち 404エラーになってしまいます。

こういったstaticなファイルしか提供できないWebサーバー向けに便利なルーターが用意されています。それがReactRouterでいうところの HashRouter です。

種類 特徴
BrowserRouter URLのパスを利用してアプリのパスを再現する。一致するコンテンツが無い時にフォールバックできるような 動的 なパス処理ができるWebサーバーが必要
HashRouter URLのフラグメント(ハッシュ)を利用してアプリのパスを再現する。静的 なファイル配信しかできないWebサーバーでも使用できる

Personiumを利用したSPAではこのHashRouterを使用して、以下のようなパスでアプリを実行できます

f:id:yoh1496:20210614181229p:plain
HashRouterを使用したURL

Version 1.7.22 から

Personiumでは、URLのフラグメントを使用するHashRouterを使用することで、アプリの1ページごとにURLを持たせることができることを書きました。

しかし、フラグメントの利用は認証連携のケースで不利になることが多いです。Personium自身がそうなんですが、OSSで提供しているホームアプリではフラグメントにセルURLを記載してアプリを起動しますし、OpenIDの認証でも、 redirect_uriにフラグメントを含められないケースが見られました。 (SPAでコールバックを受け取ろうにもフラグメント不可なのでどうしようもなく、クエリの内容からコールバックか否かを判断したりした)

そんなPersoniumですが、バージョン 1.7.22 から、エンドポイントの動的なパス設定をすることができるようになりました。

<D:propertyupdate xmlns:D="DAV:"
    xmlns:p="urn:x-personium:xmlns">
  <D:set>
    <D:prop>
        <p:service language="JavaScript">
              <p:path name="{id: .+?}" src="launch.js"/>
        </p:service>
    </D:prop>
  </D:set>
</D:propertyupdate>

このようにすることで、正規表現 .+? にマッチするパス(/__/front/ 以下)で launch.js のロジックを実行できるようになりました。

f:id:yoh1496:20210614183211p:plain
BrowserRouterを使用したURL

で、晴れてPersoniumのWebDAV上でリライトを実現することができるようになりました。これでURLをコピペしてチャットツールなどで共有しても、同じアプリ画面を知り合いに伝えることができるようになりました。

終わりに

SPAを実装するにあたって、必要になるパス周りの処理について書きました。ここらへんコミュニティのメンバーに話そうと思ってもなかなかうまく伝えることができなかったので、改めて文章にしてみました。が、なかなか難しいですね。

今は index.html の内容を返すというあまりに能が無いことをやっていますが、パスに応じてページの初期状態を動的にレンダリングして返すSSRも実現できなくもなくはないのかなと思っています(今の実装だと大変そう