気が付いたらストリーミング SSR をフルスクラッチしていた話 16KBで出来たよ
それなりの期間をかけて書いてきたコードについて、Copilot に競合や隣接するものを教えて貰ったところ、React のストリーミング SSR がソレと知りました。
僕が開発している html.json は、React のような Web アプリケーション方面ではなく、Web 文書方面から出てきた軽量ストリーミング SSR (を含むプロジェクト)です。CGI 時代にできていたことは大体できると思います。
プロジェクトに付属のサンプルを、Closure Compiler でデバッグ用コードを外して ADVANCED コンパイルした場合、わずかに 16KB でした。html.json 自体に外部依存ライブラリはなく、専用に内製した HTML パーサーはコンパイル時に最適化されて組み込まれます。
月々缶コーヒー程度の値段で維持している Node.js の静的 Web 文書サーバに、サーバ代を気にせずに動的ページを追加したい、といった要望を叶えます。
また、使ったことはありませんが、CDN エッジサーバや FaaS(Function-as-a-Service) とも相性が良いのではないでしょうか? 知らんけど。
静的ウェブページから SSR へ
次は Node.js の静的ウェブサーバのサンプルです。
http.createServer((req, res) => {
if (req.url === "/") {
res.writeHead(200, {"Content-Type": "text/html; charset=utf-8"});
fs.createReadStream("./public/index.html").pipe(res);
} else { /** TODO 404 **/}
}).listen(port, ip);
ここに HTML を変更する Transform ストリームを pipe すれば、ストリーミング SSR になります。簡単ですね。
fs.createReadStream("./public/index.html")
.pipe(ssr())
.pipe(res);
html.json での SSR
ところで html.json は、ランタイムでは HTML ではなく、これと等価な json を使用する為のライブラリです。
<p>Hello, world!</p>
↓
["P", "Hello, world!"]
HTML の json 化によって、ルーズに書ける HTML 用のパーサーをランタイムから取り除いて、厳密に書ける json 用の、つまり簡易なパーサーに置き換えることを狙います。
fs.createReadStream("./html.json/index.html.json")
.pipe(json2html.stream())
.pipe(res);
サンプルページを実行してみる
プロジェクトでは、初回アクセス時に RSS フィードを取得して、昔の Yahoo! のランディングページのように表示するサンプルを用意しています。
あらかじめ json 形式で用意してあるテンプレートを HTML に変換し、動的マークアップに出会った場合は、スクリプトを実行します。この際にまだデータが準備できていない場合、stream.stop()
でストリーム処理を一時停止します。
そして、RSS の取得が終わると stream.restart()
でストリーム処理を再開します。
重い処理などでタイムアウトした場合のマークアップ
サンプルでは実装されていませんが、重い処理の場合はタイムアウトで stream.restart()
して、代替マークアップを返すことが考えられます。
例えば、ページのコメント一覧の取得でタイムアウトしたら、次のようなマークアップを返したら良いですね。
<div class="spinner" data-api="/api/comments/000">
<noscript>
<iframe name="xxx"></iframe>
<a target="xxx" href="/api/comments.html?page=000">Reload</a>
</noscript>
</div>
このように spinner が返ったら、Ajax でコメントを取得して挿入します。併せて JavaScript が無効の場合は、<iframe>
内にコメントページを表示できるようにするのが良いでしょう。
html.json は SSR と CSR を同時に開発できるリッチなものではありませんので、それ用の処理を開発者がブラウザ側 JavaScript に用意します。工夫してサーバ側の処理とブラウザ側のコードを共通化しましょう。
なぜ Closure Compiler なのか?
TypeScript がウェブ開発のスタンダードになった現在でも、中~大規模な開発で、高度に最適化された JavaScript を得たい場合、Closure Compiler の選択肢しかありません。そして既に開発が停まって久しいですが DeNA の JSX が唯一の対抗馬でしょう。
そんな Closure Compiler ですが、Node.js 用の JavaScript 開発には殆んど使われていないようです。僕は @externs/nodejs
を使用し、(サーバ側)アプリケーションに固有なコードに対してだけ ADVANCED コンパイルを施しています。Express.js のようなライブラリ・フレームワークのコンパイルは諦めています。
これは TypeScript が行うこととは根本的に異なる。TypeScript は JavaScript を生成する際にシンボルの名前を変更したり、コードを最小化しようとはしない。生成された JavaScript を minifier にかけたとしても、これほど過激なことはしない。どのシンボルやプロパティ名が外部 API の一部であるかをミニファイアが知ることは難しい(あるいは不可能)です。そのため、プロパティ名をマングリングすることは一般的に安全ではない。TypeScript では、231バイトの「単純な最適化」出力よりも小さいものを得ることはまずないだろう。
これらの結果は、一般に gzip 圧縮後も、また大規模なプロジェクトでも維持される。私は2013年に JavaScript ライブラリを Closure に移植し、uglifyjs と比較してバンドルを40%縮小した。
これは素晴らしいことだ!では、なぜ Closure コンパイラは流行らなかったのでしょうか?
未実装の機能 DOM in Streaming
html.json のストリーミング SSR では、ストリーム中で使えるように制限を加えた DOM のサポートを検討しています。
制限付きの DOM は次のような、機械的で範囲の狭い変更を容易にします。ソースとなる html.json ファイルをコンパクトにしてストレージの節約が期待できます。
<h3>おさらい</h3>
<h4>ここまでのまとめ</h4>
↓
<h3 id="4-1"><a name="4-1">4.1</a> おさらい</h3>
<h4 id="4-1-1"><a name="4-1-1">4.1.1</a> ここまでのまとめ</h4>
現在は、レベル別の制限を検討している段階で、実装は来年に取り組みます。
それでは2025年もどうぞよろしくお願いいたします。