Web Components にも対応した SSR as a Service を作った

SSR as a Service の [Renderly](https://renderly.io) というサービスを地道に作っていましたが、先日 [Try it out](https://renderly.io/try) を公開して機能がプレビューできるようになりました。

SSR のこと、普段はあまり考えたくないです。しかし取り扱う情報がパブリックなものであれば対応しておきたいところです。

とはいえ Web Components の中で Shadow DOM を使っている場合、Shadow DOM は文字列にシリアライズするのが難しい[^1] ので、そもそも完全な SSR はできません。

[^1]: Declarative Shadow DOM などの提案はありますが、現在はまだ Shadow DOM を文字列で表現することはできません。

Isomorphic JavaScript として開発していれば [skate js/ssr](https://github.com/skatejs/ssr) を使って *ブラウザ側で Shadow DOM にリプレイスされる HTML+Script* を使うという選択肢もあります。しかし個人的には、ブラウザで動かすためのコードを書いているのであってサーバ側では極力テンプレートに触れたくないとは思います。

# TL;DR

[webcomponents.js](https://github.com/webcomponents/webcomponentsjs) の *Shady DOM 強制モード* による「Shadow DOM のただの DOM 化」を利用して、Web Components を SSR するサービスを作りました。

[Try it out](https://renderly.io/try)

以前に書いた[記事](https://qiita.com/aggre/items/9d77085a32764ffe994d)で触れた Web Components 実装による [Double O](https://ooapp.co) もこれで SSR できるはず。

設計思想として、

- ロックインの排除 ≒ SSR 対象のアプリケーションコードに変更を要求しないこと
- コンテンツ配信の不干渉 ≒ リクエストの都度実行してキャッシュ戦略などに干渉しないこと
- シンプル ≒ GET するだけで使えること

などを目指しました。最後の点については、まだプレビューなので実装できていませんが他の点はクリアしています。

ざっくり実装としては、

- どのような JavaScript アプリケーションでも SSR できるように Headless Chrome を実行
- つまり React でも何でも SSR できるはず
- なるべく賢いタイミングで HTML の完成を検知( 詳しくは後述 )
- もちろん Lambda

という感じです。

パフォーマンスについても、Chrome 起動までのオーバーヘッド以外はほぼネットワークリクエストの遅延くらいで収まりました。つまり手元のブラウザでレンダリングするのと同じくらいのパフォーマンスが出せたと思います。

# 使ってみる

[Try it out](https://renderly.io/try) ページでは API のテストができます。

プロダクションではアカウント別に設定を保持しておいて、`https://hoge.renderly.io/path` のようにリクエストをすると完成品の HTML が返ってくるようにするイメージです。

## Web Components を SSR する

[Try it out](https://renderly.io/try) の Settings のなかに JSON で設定を記述して、Test をクリックすると下のほうに結果が表示されます。

![renderly.io_try_(Mini Display).png](https://qiita-image-store.s3.amazonaws.com/0/71785/e0723d12-dcb9-ac35-5b86-c309e295c7fe.png)

すべての設定項目は [こちら](https://frame00.gitbooks.io/renderly/try.html) にまとめています。

https://shop.polymer-project.org は Polymer チームがデモで作ったショッピングサイトで、これを SSR してみます。次の設定が最小となります。

```json
{
    "debug": true,
    "source": {
        "origin": "https://shop.polymer-project.org"
    },
    "renderer": {
        "waitForDocumentSelectorMatches": "shop-image",
        "webComponents": true
    }
}
```

`debug` はプロダクションでは不要ですが、テストのときは必須のため入れました。

`source.origin` でドキュメントのオリジンを指定します。

`renderer.waitForDocumentSelectorMatches` で **CSS セレクタに一致する要素が出現するまで待機** して、条件を満たし次第にレスポンスします。[prerender.io](https://prerender.io/) などと異なるのはこの点です。単純なタイムアウトではなく、HTML が完成したときにレスポンスできるようにしました。じつは [webcomponents.js](https://github.com/webcomponents/webcomponentsjs) の Shady DOM を取得しようとしたときに想像と違うふるまいをしていたのでハマりましたが、それはまたどこかで共有したいです。

`renderer.webComponents` を `true` にすることで Web Components の SSR が有効になります。

下が実行結果です。

![renderly.io_try_(Mini Display) (2).png](https://qiita-image-store.s3.amazonaws.com/0/71785/a23bc57f-0036-e538-83d1-9748a47ab524.png)

先の例でいえば `<shop-image>` 要素が出現することを条件にしていたので、ちゃんと要素が出ています。

この SSR の結果はすでに Shadow DOM を失っているので、一般のユーザー向けにはあまり使いたくないと思います。ボット向けに割り切った用法になるかと思います。

### しかし...

Shadow DOM の DOM 化は [webcomponents.js](https://github.com/webcomponents/webcomponentsjs) に依存していて、SSR 対象のアプリケーションが webcomponents.js を読み込んでいる場合に限って動作します。

本当は `injectWebComponentsPolyfill` のようなオプションを使って webcomponents.js の依存解決を Renderly 側でしてあげたいのですが、それができるのはちょっと待ってください。対応中です。

# いろんな設定例

たぶんよくある設定例をいくつか書いてみます。

## React を SSR する

Request path には `/demo` を入れます。

```json
{
    "debug": true,
    "source": {
        "origin": "https://foxhound87.github.io/mobx-react-form-demo"
    },
    "renderer": {
        "waitForDocumentSelectorMatches": "h4"
    }
}
```

## SSR させたい HTML を URL で指定する

プロダクションではこれをしないと無限ループになっちゃいます。

```json
{
    "debug": true,
    "source":{
        "origin": "https://example.com",
        "type": "url",
        "content": "https://www.iana.org"
    },
    "renderer": {
        "waitForDocumentSelectorMatches": "title"
    }
}
```

`source.type` と `source.content` を指定します。

## SSR させたい HTML を文字列で指定する

さっきの例だと、レンダラーが外のインターネットに出るのでちょっと遅いです。なので直に HTML を指定することもできます。

```json
{
    "debug": true,
    "source":{
        "origin": "https://example.com",
        "type": "html",
        "content": "<html><head></head><body><h1>Hello Renderly</h1></body></html>"
    },
    "renderer": {
        "waitForDocumentSelectorMatches": "h1"
    }
}
```

## DOM の完成をイベントで判定する

CSS セレクタではなく `document` からディスパッチされたイベントをもとに HTML の完成を受け取ります。

```json
{
    "debug": true,
    "source":{
        "origin": "https://example.com",
        "type": "html",
        "content": "<html><head></head><body><script>setTimeout(() => {document.dispatchEvent(new Event('test'))}, 100)</script></body></html>"
    },
    "renderer": {
        "waitForDocumentEvent": "test"
    }
}
```

`renderer.waitForDocumentEvent` にイベント名を指定します。

## ステータスコードを制御する

DOM の状態からレスポンスすべき HTTP ステータスコードを判定します。デフォルトは 200 です。

```json
{
    "debug": true,
    "source":{
        "origin": "https://example.com"
    },
    "renderer":{
        "waitForDocumentSelectorMatches": "h1",
        "statusCode":[
            {
                "selectorMactches": "h1",
                "status": 201
            }
        ]
    }
}
```

`renderer.statusCode` の配列の先頭から順番に一致するまで走査して、一致した条件の `status` をステータスコードとして返します。

---

その他、すべての設定項目は [こちら](https://frame00.gitbooks.io/renderly/try.html) にまとめているので参考にしてください。


# 本番で使う

まだプレビューしかできません。[Renderly](https://renderly.io) からニュースレターを購読してくれたら、プロダクションで使えるようになったときにはメールで通知します。

## 制限

ちなみにプレビューとはいえ機能には一切制限をかけていません。`https://api.renderly.io/try` に対して `POST` リクエストするプロキシとかを作ればプロダクションで使えるといえば使えます。そういう用途は想定していないので、もしもリクエストが増えすぎたら API をスロットリングするかも知れません。ご了承ください。

---

# さいごに

個人的には JavaScript アプリケーションの開発で一番面倒だったのが SSR です。

あんなものをプロジェクト毎に作るとかもう正気じゃないだろう...と思ったので SSR as a Service を作りました。

ソースは( コミットが雑すぎるなど )いろいろあって公開してないんですが、いずれ GitHub に公開します。

役に立てればうれしいです。