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
)が違います。
- https://twitter.com/home
- https://twitter.com/notifications
- https://twitter.com/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/iii
も aaa/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で使用可能になります。
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を実行できるという寸法です。
しかし、このスクリプトが紐づけられているパスは app
1つであるため、アプリ内のパスをURLのpathで表現しようとすると(たとえば、/__/front/app/home
など)、たちまち 404エラーになってしまいます。
こういったstaticなファイルしか提供できないWebサーバー向けに便利なルーターが用意されています。それがReactRouterでいうところの HashRouter
です。
種類 | 特徴 |
---|---|
BrowserRouter | URLのパスを利用してアプリのパスを再現する。一致するコンテンツが無い時にフォールバックできるような 動的 なパス処理ができるWebサーバーが必要 |
HashRouter | URLのフラグメント(ハッシュ)を利用してアプリのパスを再現する。静的 なファイル配信しかできないWebサーバーでも使用できる |
Personiumを利用したSPAではこのHashRouterを使用して、以下のようなパスでアプリを実行できます
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 のロジックを実行できるようになりました。
で、晴れてPersoniumのWebDAV上でリライトを実現することができるようになりました。これでURLをコピペしてチャットツールなどで共有しても、同じアプリ画面を知り合いに伝えることができるようになりました。
終わりに
SPAを実装するにあたって、必要になるパス周りの処理について書きました。ここらへんコミュニティのメンバーに話そうと思ってもなかなかうまく伝えることができなかったので、改めて文章にしてみました。が、なかなか難しいですね。
今は index.html
の内容を返すというあまりに能が無いことをやっていますが、パスに応じてページの初期状態を動的にレンダリングして返すSSRも実現できなくもなくはないのかなと思っています(今の実装だと大変そう