Firebase Hosting のルーティングを噛ませて webpack-dev-server などの静的コンテンツホストを使うためのプロキシ

2019/1/14 午前5:57 (JST) provider のコードを修正。

概要

Firebase Hosting 用に設定したURL書き換えを利用しつつ、webpack-dev-server などの他の開発サーバーからコンテンツの取得をしたい。この問題に対して、Firebase Hosting の開発サーバー (firebase serve) が内部的に利用している superstatic を使い、URLを書き換えた上で別のサーバーからコンテンツを取得するようなプロキシを作ることで解決した。

完全なコードは https://gist.github.com/mecab/9890416390e8c5a2c42547823d252e20 で参照できる。

背景と問題

Firebase Hosting は Google が提供する静的コンテンツのホスティングサービスだ。コンテンツを用意して適切な設定を書けば、そのコンテンツをHTTPで取得できるように Google が管理するインフラの上でホスティングされる。

単にファイルをホスティングするだけではなく、簡易なルーティング機能も用意されている。特定のURLパターンに対してURLのリライティングやリダイレクションといった書き換えを設定することができる 1

上記のルーティング機能を使うことで、例えば /user/{user_id} に対する要求に対して、/user.html のファイルの中身を返却することができる。こうしておけば、user.html からブラウザで実行される Javascript (JS) が URL から {user_id} を読み出し、動的に外部から当該ユーザの情報を引き出し表示するといったことができるだろう。

この機能は便利だが、開発時には都合が悪いことがある。すなわち、ローカル環境で Firebase Hosting 用に設定したルーティングを反映させて確認しづらいということだ。一応、Firebase hosting が提供するコマンドには開発サーバー機能 (firebase serve) があり、設定が反映された動作を確認することができるが、ルーティングは活かした上で、サーバーには別のものを利用したいという状況がある。

例えば、開発サーバーとして webpack-dev-server を使いたいという状況がひとつの例だろう。Webpack は JS や CSS 、あるいは画像といったアセットをバンドルする仕組みだが、これで生成されたアセットを webpack-dev-server がホストしてくれる。単純にホストするだけではなく、元ファイルが変更された際にアセットを再生成した上で、ブラウザを自動的にリロードしてくれる。ここに、Firebase Hosting 用に設定したURLの書き換えを噛ませた上で、webpack-dev-server を利用したいという状況が存在する。

この問題に対して、Firebase Hosting のルーティングを解釈してURLのパス部を書き換えた上で、webpack-dev-server のような別の開発サーバーからコンテンツを取得して表示するプロキシを作成するという方針で解決することができたので、本稿ではその方法について紹介する。

実装 - superstatic 用のカスタム provider を作る

Firebase が提供している superstatic という npm モジュールがある。これは Firebase の裏側で実際に使用されていると思われるもので、Firebase Hosting で使われる設定ファイルを解釈して、URLの書き換えを行った上で静的なコンテンツを提供する。

superstatic では、オプションとしてカスタムの provider を与えることができる。この provider は、superstatic が指定したパスに対応するコンテンツを実際に取得するはたらきをする関数だ。当然ながら、デフォルトではファイルシステムからデータを取得している。この provider として、ファイルシステムではなく、他のサーバー(つまり、webpack-dev-server などの自分が使いたい開発サーバー)からデータを取得するものを作って渡せば目的が達成できる。

provider の仕様としては、リクエスト (http.ClientRequest) と パス (string) を受け取り、

  • 対応するコンテンツが見つかれば内容とメタデータで resolve
  • 見つからなければ null でresolve
  • エラーの場合は reject

するような Promise を返すような関数を作成すれば良い。コンテンツが見つかった場合の resolve は、具体的には

  • stream: コンテンツの内容を表すストリーム
  • size: コンテンツのサイズ
  • etag: コンテンツによってユニークな文字列(ハッシュなど)
  • date: コンテンツの最終更新日

を含むオブジェクトで行う 2。これを元に以下のような関数を作成した。例えばcreateProxyProvider('http://localhost:8080') とすると、localhost:8080 で動くサーバーからコンテンツを取得するための provider が生成される。

2019/1/14 午前5:57 (JST) 更新。 元のリクエストヘッダである req.headersrequest に渡さないようにした。If-Modified-Since などのキャッシュ関連のヘッダが渡され、プロキシ先で 304 Not Modified が返された場合にコンテンツを返せずエラーになってしまうのでそもそもヘッダ自体を渡さないようにした。キャッシュ関連のヘッダのみを削除する実装が正しいはずだが割愛。
あわせて、end イベントの処理を削除。そもそも pipe() しているのでストリームを閉じる処理は自動で行われるはず。
差分は gist で確認してほしい。

const stream = require('stream');  
const request = require('request');

function createProxyProvider(base) {  
    return (_req, path) => {
        const proxyPath = base + path;
        const passThrough = new stream.PassThrough();
        return new Promise((resolve, reject) => {
            request(proxyPath)
            .on('error', (err) => {
                reject(err);
            })
            .on('response', (res) => {
                if (res.statusCode != 200) {
                    resolve(null);
                    console.error(`statusCode ${res.statusCode} for ${proxyPath}`);
                    return;
                }

                const size = res.headers['content-length'];
                const etag = res.headers['etag'];
                const date = res.headers['date'];
                resolve({ stream: passThrough, size, etag, modified: date });
                res.pipe(passThrough);
            });
        });
    }
}

何故か request から返されたレスポンス (res) を直接 stream として返しても、長さゼロのコンテンツとみなされて上手く動かなかったのだが、一旦 PassThrough ストリームを経由させたら動いた(ナンデ!?!??!?)

後は上記関数から得られる provider を渡して superstatic のサーバーを起動すればよい。

const superstatic = require('superstatic');  
const firebaseConfig = require('firebase-tools/lib/config').load({ cwd: __dirname });

const port = 8081;  
const proxyBase = 'http://localhost:8080';

const app = connect();

app.use(superstatic({  
    config: firebaseConfig.data.hosting,
    provider: createProxyProvider(proxyBase)
}));

app.listen(port, () => {  
    console.log(`Superstatic proxy is running on port ${port}. Proxy base is ${proxyBase}.`);
});

この例では、localhost:8081 以下にアクセスすると、Firebase Hosting のルールによるURLの書き換えが行われた上で、実際のコンテンツは localhost:8080 から取得される。

注目すべきは require('firebase-tools/lib/config').load({ cwd: __dirname }); の部分で、これを使えば cwd で指定したディレクトリで使用されるべきfirebase の設定ファイル (firebase.json)を自動で探してくれるので便利だ。なお、superstatic に渡す際には設定オブジェクト内の hosting 以下のみを渡さなければいけないので気をつけてほしい。

完全なスクリプト

上記をまとめ、コマンドライン引数で portproxyBase を設定できるようにしたスクリプトを gist に公開した。

具体的な利用例として、npm scriptsに

"debug": "concurrently --kill-others -n 'webpack,proxy' 'webpack-dev-server' 'node proxy.js'",

などと追加することで、npm run debug で webpack-dev-server と 上記設定を行った superstatic を同時に起動することができ、ブラウザから superstatic のURL (デフォルトでは8081)を叩くことで、Firebase Hosting のルーティングを反映させた上での webpack-dev-server からの結果を見ることができる。

参考にしたサイト

  • Webpack Dev ServerとFirebase serveを一緒に使う - Qiita
    superstatic を使うという着想はこの記事から得た。記事中では webpack の設定で devServer.before、つまり webpack-dev-server のサーバー機能の前段に superstatic をミドルウェアを差し込むという方法を取っている。
    残念ながらこの方法は筆者の環境では動作しなかった。詳しく検証はしていないが、 superstatic がURLをリライトする際、ファイルシステム上に実体がない場合に即座に404が返されるのが原因のようだ。webpack-dev-server はアセットをメモリ上にビルドしてメモリ上から提供するため、ファイルシステム上に実体が存在したいためエラーになっているように思う。write-file-webpack-plugin などを使って、webpack-dev-server 環境でも強制的にファイルシステム上にアセットを書き出すようにすれば解決するかもしれない。また、URL書き換え以外の一部機能は動作していた。