この記事はすべての人に適しているわけではないかもしれません。書かれている内容を理解するには、少なくともフロントエンドエンジニアの卵かフルスタックエンジニアであり、SSR、SSG、ISR といった概念について基本的な理解があり、少なくとも Next.js を使った経験が必要です。
はじめに
私が生まれつき基礎原理に強い好奇心を持っているせいかもしれませんし、時にはなぜそれができないのかを徹底的に質問し続けることがあります;そのようなことがあるからこそ、ときどき独自のアイデアが思いつくことがあります:これもその一つで、25年11月にすでに姿を現しました。当時は多くの友人と話し合いましたが、手元のプロジェクトのスケジュールが重なり、2026年の正月前後まで実際に手を動かしてバージョンを作るのが遅れました。その後、サイト構築に取り掛かったため再び中断しました。ブログはまだ完全に立ち上がってはいませんが、少なくとも記録できる場所ができたので、改めてこのことを取り上げて語り直したいと思います。
まず最初に一つお断りしておきますが、私は SSR がダメだと言っているわけではありません。ただ、私が目にしてきた傾向からすると、あまりにも多くの人がそれを乱用しており、全体として流されるままにある問題を回避しているように思えるのです。このことについて多くの人と議論してきましたが、返ってくる反応の多くは曖昧なものでした。自分の構想を話したこともありますが、相手はいつもそこに様々な問題やエッジケースが存在するのではないかと懸念していました。それに、正直なところ毎回この思考プロセスを最初から最後まで一通り説明するのも面倒でした。そこで今日は、いっそのこと詳細に書き残し、いくつかの疑問や技術的な原理を明確に説明してみようと思います。
SSR の乱用
私が Web フロントエンド開発に入門したばかりの頃、最初に触れたフルスタックフレームワークはおそらく Next.js でした。それが非常に優れたフレームワークであることは認めます。パフォーマンス上の問題がいくつかあるとはいえ、それらの問題は確かな DX 体験と引き換えに得られたものであり、責められるべきではありません。しかし、フロントエンドの概念や知識を徐々に深く理解するにつれて、自分にはだんだん合わなくなってきたと感じるようになりました。というのも、私は Rust を書くのが大好きで、低レイヤーの研究も好きで、組み込みシステムを触った経験もあるため、パフォーマンスに対して特別なこだわりがあるからです。こだわりと言っても何でも極限を追求するわけではなく、明らかに性能が大幅に良く、しかもわずかな努力で実現できる方法がここにあるのに、なぜ誰もそうしないのか?という疑問です。そのため、Next を使うたびに考えてしまいます。なぜ、ごくシンプルなページであっても、ほとんどの部分が固定でたった1箇所が変わるだけなのに、ページ全体を再レンダリングしなければならないのか?なぜなのか?私の理解では、バックエンドが Rust で書かれていなくても、極限を追求していなくても、JavaScript インタプリタ上で動いていたとしても、実際に変更されたわずかなデータは相対的にかなり小さなオーバーヘッドであり、ページ全体を再レンダリングするのではなく、ごく低コストな方法で「レンダリング」を実現すべきなのです。
その結果、後々になってそれが私の中で大きな引っかかりとなり、どうしても考えざるを得ない問題となっていました。しかし皮肉なことに、フロントエンドのここ10年近くの開発の歩み—Vue、Svelte、Solid とそれらのフレームワークである Next.js、TanStack Start、Remix、Nuxt、Sveltekit、Soild Start を一通り見返したとき、誰もが脱線してしまっているように感じられました。特に React の RSC は間違いだと感じていますが、その存在にも一理はあるので、機会があれば後で詳しく話すかもしれません...
私に言わせれば、RSC や数多くの SSR フレームワークは、実のところ render を乱用しているように思えます。SSG は私が求めていたスタイルを実現しており——ページはビルド時に生成され、リクエスト時に再レンダリングを必要としません——しかし、本当の意味での動的な機能(x)を持っていません。一方、ISR は SSG と SSR の間でバランスを取り、ある程度の柔軟性を実現しているように見えますが、本質的には私の期待を満たしていません。ISR が登場したのは結局のところ SSR のパフォーマンスの低さを補うためであり、その後の各種 CDN キャッシュ戦略といった操作も含め、それらは実質的に一種の workaround に過ぎず、真にエレガントな解決策とは言えないと私は考えています。
もちろん SSR それ自体は間違いではなく、多くの現実のユースケースを解決してくれる素晴らしいものです。しかし問題なのは、これらのフレームワークが SSR を極端なデフォルトの選択肢にしてしまった結果、開発者が実際に SSR を使っている場面のおそらく 95% では本当はそれが全く必要ないという点です。ページ上の大部分のコンテンツは固定されており、本当に動的な部分はごく僅かなのに、リクエストのたびにコンポーネントツリー全体を実行しなければなりません。
とはいえ近年の React には改善も見られ、React 19.2 には PPR が導入されています。しかし、これには実行時に高価な renderToString() が伴うため、一種の workaround に過ぎず、真の解決策ではありません。そこで私は、大部分のものがビルド時にすでに確定しているのであれば、レンダリングそのものを直接コンパイル期に行えばよいのではないかと考えました。
コンパイル時レンダリング?
レンダリングをコンパイル期に置くというのは一見すると非常に荒唐無稽なことに思えます。というのも、SSR が存在する根本的な理由は、一部の値や条件が実行時というその瞬間までどうしても解決できず、それが何であるかも分からず、判断を下せないからです。ですから、多くの人が私の考えを真っ先に否定したのも無理はなく、理解できます。
ですが、実際にはこれを実現するためのなかなか精巧な Pipeline を思いつきました。これが、これを一種の Protocol と呼ぶ必要がある理由でもあります。それに、フルスタックフレームワークがフロントエンドとバックエンドの境界を混同させるのはとても愚かなことだと常々思っています(近年の Next.js がこの点で DX を非常にうまくやっており、多くの初心者がフルスタックアプリを書くのはとても簡単なことだと感じているのは認めざるを得ませんが、実際には多くのセキュリティ上の懸念もここから生じています。話が逸れました)。
ですからコンポーネント内でデータ取得を書くのも同じことで、私は境界が明確なアプローチを好みます——コンポーネントは純粋なコンポーネントであり、データ取得は完全に抽出すべきなのです。そして、一度この前提を受け入れ、純粋なコンポーネントとデータを分離すると、話が面白くなってきます。データそのものが実は分類可能であることに気づくのです。
ここでも TypeScript からインスピレーションを受けたことに感謝したいです。実際のところ、いわゆる「値」自体がコンパイル時に何であるかは重要ではなく、重要なのはその型です。slot にどのような内容を埋めるかについても、Open String(無限の可能性を持つ文字列)のようなものでない限り、有限の可能性を持つ型として包み込むことができます。例えば、dashboard を書いていて特定の内容を条件付きレンダリングする必要がある場合、User や Admin などにすぎませんが、いずれにせよ定義しきれる型です。現実世界のほぼすべての条件付きレンダリングやロジック判断が必要な箇所は、いくつかの確定した可能性に集約できます。
ちょうどこれらの可能性は、ある優れた方法で記述することができる。それが JTD だ!
JTD 仕様
JTD (JSON Type Definition) RFC 8927 の定義では、8 つの Schema Form が存在します:Empty、Ref、Type(boolean、string、timestamp、およびさまざまな精度の数値型)、Enum、Elements、Properties、Values、Discriminator です。さらに、任意の schema は nullable としてマークできます。JTD の利点は言語を横断できる点にあり、JavaScript、Rust、Go など、ほぼすべての言語で対応する型マッピングが見つかります。これにより、フロントエンドとバックエンドの架け橋として自然に機能し、その担い手である JSON はもともとフロントエンドとバックエンドの最小公約数です。現実世界のウェブサイトの 95% のアプリケーションは、文字列や数値フィールドを表示するか、ブール値で条件判断を行うかに過ぎず、それらはすべてこの範囲に収まります。この点を明確にすれば、実際に操作できる空間は非常に広がります。もちろん、JTD も完璧ではなく、Markdown のような例外も存在しますが、それについては後ほど詳しく説明します。こここそが、私が SSR の真の意義を認めている部分です。
Sentinel
まずは簡単な方から話しましょう。すべての動的な値に型があることは既にわかっているので、コンパイル時に React コンポーネントを renderToString() で一回実行することができます。ただし実際のデータではなく、Sentinel でモックしたデータを使います。では、どういう意味でしょうか?
{ user: { name: "Alice", age: 30 } }データがこのようになっていると仮定すると、それを次のように置き換えることができます。
{ user: { name: "%%SEAM:user.name%%", age: "%%SEAM:user.age%%" } }これらの %%SEAM:...%% が Sentinel であり、すべての動的な値の位置を占めています。React がこれらの Sentinel データを使って renderToString() を実行した後、出力される HTML 内のこれらの位置がマーキングされます。次に、ビルドパイプラインがこれらのセンチネルを HTML コメント形式の slot マークに変換します。
<!-- Sentinel -->
<span>%%SEAM:user.name%%</span>
<!-- Slot -->
<span><!--seam:user.name--></span>ここまで来ると、これらの slot が 「型付きスロット」 であることに気づいたかもしれません。この時点で、サーバーサイドランタイムがすべきことは極めてシンプルになります。実データを取得して、これらの穴に値を埋め込む、純粋な文字列置換です。renderToString() はまったく不要で、JavaScript ランタイムも不要、vDOM も不要です。HTML コメントを解析でき、文字列置換ができる言語なら何でもバックエンドになれます。Rust、Go、TypeScript、どれでも構いません。だからこそこれはフレームワークではなくプロトコルなのです。
この時点で、「条件付きレンダリングはどうするのか?」と思うかもしれません。たとえば、あるフィールドが null のときにコンテンツブロック全体が表示されるべきではないケースです。しかし、実際にはこれも実行時の JavaScript で判断する必要はなく、むしろプロトコル側でこの状況を定義できます。
<!--seam:if:user.avatar-->
<img src="<!--seam:user.avatar-->" />
<!--seam:endif:user.avatar-->条件付きレンダリングも同様で、リストレンダリングも同じです。
<!--seam:each:messages-->
<li><!--seam:$.text--></li>
<!--seam:endeach-->パターンマッチングでさえも
<!--seam:match:status-->
<!--seam:when:active--><span class="green">Active</span>
<!--seam:when:disabled--><span class="red">Disabled</span>
<!--seam:endmatch-->これがコメントの芸術であり、ここでコメントが選ばれたのは単にそれが合法的な HTML 文字だったからに過ぎません。では、これらの条件ブロックやループブロックは、どのようにして build-time に決定され、生成されるのでしょうか? 実はこれも非常に巧みな方法で、vDOM がなくても HTML を2回レンダリングして Diff を取ることができます。条件付きレンダリングを例にとると、1回目は完全な Sentinel データでレンダリングし、2回目は特定の nullable フィールドを null に設定して再度レンダリングします。2つの出力を比較すると、消えた HTML の部分がそのフィールドによって制御されている条件ブロックだとわかるので、直接 <!--seam:if:...--> で囲むだけで済みます。配列も同様の仕組みです。
もしかして CTR?
そうなると、きっとまた「条件レンダリングの組み合わせが指数爆発するのではないか?」と心配する人が出てくるでしょう。例えば 3〜5 個の変数が条件レンダリングを制御していて、各変数に 10 通りの可能性があるとすると、掛け合わせると確かに見た目は大きな数字になります。しかし実際には、この数字は見かけ倒しに過ぎません。まず、多くの文字列型の値は内容を本当に網羅する必要はなく、値があるかないか nullable ——ここでは実質 2 通りだけを気にすればよいのです。次に、本当に条件判断が必要なフィールドの型は、必ず網羅可能なものになっているはずです。例えば(まさか open string で if の判断をしたりはしないでしょう?)。それに、たとえすべての組み合わせを網羅するとしても、現代の CPU であれば実行にかかる時間はせいぜい数 ms でしょう。しかもこれは完全にコンパイル時の重さであり、Rust のコンパイルと同様に、コンパイル時に重いコストを支払う代わりに、実行時には純粋な文字列置換になります。どう考えても得な取引ですし、ここには何の特別な魔法もありません。ただし、1つ伏線を残しておくと、後半で複雑な推論を行うためにごく小さな組み込み JavaScript 実行環境を導入しています。これは一種の妥協であり、その理由や、理論的には不要にできることについては後ほど説明します。
これが私の言う CTR (Compile-Time Rendering) です。制限に関して言えば、SSR の制限とまったく同じです。たとえば、<!--seam:path--> でテキストを挿入する際には自動的に HTML エスケープが行われ(&、<、> など)、生の HTML を挿入したい場合は明示的に <!--seam:path:html--> を使用する必要があります。データパスが欠損している場合、テキスト slot では空文字列になり、属性 slot では注入がスキップされます。each ブロックに配列以外のものが渡された場合はそのままスキップされます。これらの動作は、SSR フレームワークで遭遇するエッジケースと本質的に違いはありません。私は単に SSR におけるレンダリングステップをコンパイル時に移動して実行しただけに過ぎません。そして CTR は、build-time においてすべての条件変数の型値に対してデカルト積のトラバースを行い、型に適合する任意の Mock 値、またはユーザーが Override した特殊な Mock 値(通常、HTML のバリエーション生成に必要な Mock 値を手動で記述する必要はありませんが、手動で与えることも可能です。基本的には型に合った値が自動的に渡されます)を使用します。このようにして各組み合わせを 1 回ずつレンダリングし、すべての条件ブロックとループブロックの境界を diff して、最終的に完全に展開された HTML スケルトンを取得します。数学的に言えば、runtime に渡されるデータがこれらの型定義に適合している限り、このスケルトンに確実に正しく注入されます。あらゆる可能性のある分岐パスは、コンパイル時にすでに網羅されているからです。
整合性?
では一致性はどのように保証すればよいのでしょうか?実はその答えが JTD という Contract なのです。フロントエンドとバックエンドが分離されていても、両者が同じ JTD schema に従っている限り、データの型は揃うため、不一致の問題は発生しません。
しかし、私は他のフレームワークよりも1つ多くの課題を抱えていました。バックエンドが JavaScript ランタイムから解放された以上、フロントエンドと一体である必要はなく、Rust や Go を使ってバックエンドを書くことも完全に可能です。TypeScript のフルスタックフレームワークであれば、フロントエンドとバックエンドの一貫性は型によって直接保証できますが、他の言語ではどうでしょうか?実際、ここでは codegen を使用しました。バックエンドがどの言語で書かれていてもバックエンドを基準とし、フロントエンドで利用できる変数や型を直接 TypeScript へ codegen してフロントエンドにインポートさせます。これにより、フロントエンドとバックエンドの境界は明確に分けられますが、実際には TypeScript のフルスタックフレームワークのように1つのフォルダ内に共存でき、Monorepo の習慣に適合し、手動で API や gen OpenAPI などを書くことなく直接相互呼び出しが可能です。実際には、フレームワークのデフォルトエンドポイントとしてプライベートパス /_seam/ を使用しており(Nuxt と同様に設定可能)、JTD Typed-RPC を実行することでデータ伝送と CORS の問題が解決されます。
CTR x SSR
そして raw HTML slot ですが、これは要するに例の 5% の Edge Case に戻る話で、例えば Markdown や Rich Text のようなものは、コンパイル時にその値を知ることは根本的に不可能ですし、型で制約をかけるコストが非常に大きいのです。もちろん、プロトコルを拡張して Markdown のすべての構文を列挙し、実行時に解析すればいいと言うこともできますが、その作業量は実質的に Markdown レンダラーを書き直すようなものではないでしょうか?一方で、最小限のいくつかの型だけを制約することは、レンダリングエンジン全体を書き直す作業量とは完全な別物であり、これも私が最初に述べた哲学、より少ないコストで、よりエレガントな方法で問題を解決するという話に戻ってきます。
なので朗報なのは、raw HTML slot によって実際には CTR と SSR を共存させられるということです。これは、ページの大部分の UI を CTR で構築でき、ランタイムコストがほぼかからないことを意味します。そして最も核心となる Markdown の記事については、SSR を使ってレンダリングします。バックエンドが解放されたため、TypeScript を使って元の SSR のレンダリング手法をインポートすることもできますし、視野を広げることもできます!バックエンドに Rust を使っているなら、当然 Rust の Markdown コンパイラを使ってレンダリングすればよく、いずれにせよ最終的に HTML 文字列を渡してこの raw slot に挿入すれば表示できます。CTR を使ったからといって SSR が使えないわけではなく、両者は完全に共存できます。
PPRの理想と現実
こう理解すると、CTR は本質的に PPR(Partial Prerendering) の最も理想的なケースだと言える。キャッシュ可能なものはすべてコンパイル時にレンダリングされ、ランタイムのオーバーヘッドはゼロ、実際に変化するわずかなデータだけにコストがかかる。では React 19.2 の PPR と何が違うのか?答えは、私の方がより徹底的で、より積極的だということだ PPR たとえ最も理想的な場合であっても、静的な部分がすべてキャッシュされていて、更新が必要なのはごく小さな動的コンテンツだけだとしても、その動的コンテンツが単純な文字列の変化に過ぎなくても、React はやはりもう一度 renderToReadableStream() を実行し直さなければならず、そのコストは決して小さくない。
一方で CTR はこの境界線を非常に明確に引き分けています。文字列、数値、ブール値といった単純な型であれば、すべて直接文字列置換が行われます。Markdown のように網羅コストが極めて大きい複雑な型のみが、本物の render プロセスを通ります。しかも、ここでの render はもはや伝統的な意味での SSR の概念ではなく、あらゆるプログラミング言語のレンダリング方式であり得るのです。
RSC も欠かせない
それでは RSC (React Server Component) について改めて話しましょう。以前 RSC は間違いかもしれないと述べましたが、それはフロントエンドとバックエンドの境界を曖昧にしすぎたから、多くのセキュリティ上の懸念をもたらし 多くの CVE を引き起こしたから、ですが実際には、これはどちらかといえば Next.js やその他の SSR の流れによる失敗であり、ユーザーには使わないという選択肢がそもそもない状態になってしまい、動的にしたいなら SSR にしなければならない、という風になってしまいました。しかし実際には、私たちのような文字列置換でも完全に成り立ちます。また認めざるを得ないのは、RSC がサーバー側に「任意の React コンポーネントをレンダリングする」、つまり「任意のコードを実行する」という非常に重要な能力を付与したことです。
しかし、任意のコードを実行しようとする以上、型を使ってそれを網羅的に列挙することはほぼ不可能ですし、仮に可能だったとしても作業量は React コンパイラを書くことより少ないとは限りません。そのため、RSC のような機能が存在する理由はあるのです。CTR と RSC は共存できるでしょうか?答えは「できる」であり、まったく衝突しません。ただし、これはフレームワーク層で解決すべき問題であり、プロトコル自体の範囲ではありません。私はまだ実装していませんが、TanStack Start を参考にすればよさそうです。html パッケージと js パッケージを 1 つずつ多く送信するだけで、理論上の実装は非常に簡単ですし、少なくとも明確に見えている作業量です。
raw HTML slot に話を戻すと、この解決策は主に Markdown や Rich Text のようなユースケースを対象としています。レンダリングされた HTML は dangerouslySetInnerHTML 経由で注入され、ハイドレーション後はこの領域はインタラクションに関与せず、「死んでいる」 状態になります。この部分のコンテンツは最小限に抑えるべきであり、記事に枠線やスタイルを追加したい場合は、この HTML 内に混在させるのではなく、React コンポーネントを用いて記述すべきです。この手法のデメリットはハイドレーション後に変更できない点ですが、この前提条件のもとでは「SSR」のコストを極めて低く抑え、Zero Cost に近いものにできます。実際に SSR が必要とされるシナリオのうち、およそ 60% はこうした静的 HTML の注入ケースであり、残りの 40% になって初めて RSC のようなサーバー上で任意のコンポーネントを実行する機能が必要となります。
フロントエンド UI 非依存性
最後は、非常に魅力的なプロトコル非依存性です。本質的には renderToString というキーポイントさえ押さえておけばよく、フロントでどの UI フレームワークを使用するかはプロトコルと何の関係もありません。そうなると、Astro とは何が違うのでしょうか?ご安心ください、私が作っているのは決してもう一つの Astro ではありません。一見すると Astro のアイランド構想と少し似ているように見えますが、実際には大きく異なります。
私は単一のページ内で複数のランタイムをハイドレーションしているわけではありません。Humm、正直なところ、これが本当に必要になる場面は非常に限られていると思います。異なる技術スタック間でのコンポーネントの状態通信は非常にコストが高くなりますし、一度にすべてを置き換えられない場合に、ある技術スタックから別の技術スタックへ移行するための過渡的なソリューションに近いです。第二に、Astro は本質的に MPA ですが、私たちはハイドレーション前は MPA、ハイドレーション後は SPA に変化させることができます。Next.js のようにクライアントサイドルーティングを備え、ページ間アニメーションを実現できますが、これは Astro が喉から手が出るほど欲しがっているものです。
Astro と SSG
また、Astro の設計はもともと複数の技術スタックを跨ぐシナリオでこそ真価を発揮するものであり、もしスピードのために使っていて単一のフレームワークしか使わない(例えば React だけを導入して Vue は入れないなど)のであれば、それが謳う 「速い」 というのは偽命題だと感じます。ファーストビューの読み込みがすべて HTML であることは確かですが、何らかのインタラクションを行いたい場合はハイドレーションが必要となり、そのハイドレーションのコストは React Runtime 全体をダウンロードすることであって、これは私たちのハイドレーションと本質的な違いはありません。もちろん、将来的には私たちが Island の概念を取り入れ、shell router を追加してUI フレームワークを跨いだ SPA ナビゲーションを実現することも可能ですが、それは今後の Roadmap の話であり、少なくとも今は急いでいません。
最後に、従来の SSG との比較です。私は SSG と同じことを実現しました。本質的にはコンパイル時に確定できるものをすべてレンダリングしておくということです。しかし、私たちはより動的です。なぜなら、単純な型の slot 値は実行時に完全に置き換えることができるからです。SSG を一種の MPA のエントリーポイントとしてレンダリングし、ハイドレーション前は MPA、ハイドレーション後は SPA に変化しつつ、"動的" な能力も保持していると理解してください。
もしかしてハイドレーションエラーとお別れ?
最後は私が大嫌いな hydration mismatch です。あなたも嫌いでしょう。ですが、結局のところこれは DOM の状態が一致していないだけであり、Next.js の App Router のような従来のフレームワークはアプリケーション全体を React で包もうとするため、ユーザー側でブラウザが何らかのタグを注入すると、ハイドレーションエラーが発生する可能性があります。私たちの制限は従来の SSR と同じですが、TS を使う際に __root という名前のハイドレーション div もラップしたため、ハイドレーション領域が metadata 領域を覆わなくなり、より耐久性の高いハイドレーションを実現しました。さらに、React 19 には <title>、<meta>、<link> などのドキュメントメタデータタグのサポートが組み込まれています。ページ内の特定の <div>(<html> 全体ではなく)だけをハイドレートする場合でも、コンポーネント内で <title>My Page</title> を直接レンダリングすれば、React が自動的にそれを <head> へ hoist してくれます。
hydration mismatch に戻ると、コンパイル時にすべての slot の型があらかじめ分かっているため、CTR 等価性チェックを1つ追加できます。それは、型定義に基づいて導出されたモックデータを完全に展開された HTML に流し込み、従来の renderToReadableStream() を呼び出して1回実行した上で、両者の DOM-Tree の意味が等価であるかどうかを比較するというものです。フォーマット(fmt)の違いは気にせず、フォーマット上の細かな差異があったとしても、DOM 構造が厳密に完全等価でありさえすれば、実は hydration mismatch とおさらばできるのです。なぜ従来の SSR では不可能なのか?それは実行時にこれを行うからであり、CTR 構造においてはコンパイル時にこれらの制約をすべて適用させなければならないからです。もちろん、私たちにも any という逃げ道(escape hatch)が用意されていますが、少なくとも TypeScript と同様に、any を使う場合は自己責任となります。CLI コンパイル時に warn が発生し、any の逃げ道 open string が mismatch を引き起こす可能性があることが知らされます。
Serverless の探索
もちろん Serverless の可能性も欠かせません。ここ数年、 Serverless の体験は非常に素晴らしいと言え、私としては「費用がかかること以外に欠点はない」と評価しています。しかし、CTR は本質的にこのシナリオに非常に適しています。私たちのランタイムで行う処理は非常に軽量で小さいため、Serverless 上で動かすと非常に高速になり、レスポンス時間とオーバーヘッドの両方が大幅に改善されます。改善できない残りの部分は Markdown のレンダリングのようなシナリオですが、それはビジネスロジックで最適化すべきことです。例えば、Markdown を事前にレンダリングして保存しておき、リクエストごとに再レンダリングしないようにすることです。これはまさに私のこのウェブサイトで行っている通りです。これはビジネス層の問題であり、フレームワークが解決できるものではありませんが、フレームワークはそれ以外のシンプルなロジックのオーバーヘッドをほぼゼロに抑える手助けができます。従来の SSR と比較すると、オーバーヘッドの桁が全く違います。どれくらい小さいかというと、およそ数百 us 〜 1ms 程度の話であり、これは従来の SSR では考えられないことです。
では、バックエンドが他の言語の場合はどうでしょうか。Cloudflare Workers を例にとると、実際のところ多くの Serverless が WASM に対応しており、他の言語も WASM BIN にコンパイルすれば同じようにバックエンドとして利用できます。本質的には、フロントエンドを CSR のように純粋な静的リソースにコンパイルしているだけですが、この静的リソース群がプライベートブリッジ /_seam/ と連携することで、本物のフルスタックフレームワークと同等の動的な機能を実現でき、当然 Serverless との互換性も完璧です。
SeamJS
所以、Seam と SeamJS の関係で頭がこんがらがっていませんか?実はこの2つはとてもシンプルです。Seam はプロトコルであり、Sentinel で動的な位置をマーキングする方法、slot マーカーに変換する方法、条件ブロックやループブロックの diff 検出を行う方法、そして実行時に AST に基づいてデータ注入を行う方法を定義しています。プロトコル自体は言語に依存せず、HTML コメントを解析でき、文字列置換が行えるバックエンドであればどれでも実装可能です。SeamJS はフレームワークであり、このプロトコルに基づいて作成された具体的な実装です。Vite、TanStack Router、TanStack Query といった車輪を繋ぎ合わせ、それらがカバーしきれない部分(例えば、私が作成したこの skeleton 抽出、注入エンジン、CLI など)を補っています。
正直なところ、SeamJS はまだ非常に基礎的な段階にあります。動かす分には問題ありませんが、実際にプロジェクトを記述するとなると、まだかなりの磨き上げが必要です。データ伝送チャネルの抽象化など、アーキテクチャ上の改革についても色々と考えていますが、今後のバージョンでもこの方向性を維持するかどうかは確信が持てません。フレームワークの観点からは、TypeScript のフルスタックフレームワークと、Rust をバックエンドとするフラッグシップフレームワークを必ず作成するつもりです。Go 関連の実装については今後のバージョンで削除する予定で、自分自身の精力ではメンテナンスしきれなくなったと感じています。
単なる Web フレームワークにとどまらない
では、これを作って一体何の役に立つのでしょうか? 実際、Web だけに限られません。例えば、Transport チャネルも抽象化してしまえば、Electron や Tauri といった Desktop 環境へ後から移植できます。HTTP 輸送パイプラインを IPC 通信に置き換えるだけで、動くのは全く同じ Seam プロトコルです。そのおかげで、それらの Electron アプリは起動時に loading を表示する必要が一切なくなり、多くのものを SSR のように直接ローカルでレンダリングできるようになります。これこそが CTR の魔法なのです!
No JS Runtime の代償
では、なぜ最終的に SeamJS は JS Runtime を導入することになったのでしょうか?このソリューションを実装する際、ある問題に直面したからです。それは、CTR が理想とするファーストスクリーンデータへの制約が極めて厳格であり、データは完全に導出可能な確定した構造体でなければならず、計算ロジックすら持てず条件分岐のみでなければならないという点でした。これは非常に厳しい条件となります。フレームワークとしての開発者体験を考えると想像がつく通り、従来の React 開発者はコンポーネント内でいくつかの計算を行い、得られた値をそのまま使うことを期待しています。CTR の制約に厳密に従って ready-to-display の状態を達成しようとすれば、作業は非常に煩雑になり、1つのコンポーネントを2回書くことすら生じてしまいます。
しかし、実際には解決策があります。Web の都合上、フロントエンドは JavaScript 上でしか動作しないため、当然ながらコンポーネントも JS でしか動作しません。そのため、この問題を解決するにはバックエンドに JS の実行能力が必要になります。TypeScript フルスタックプロジェクト自体は既存のランタイムで解決できますし、Rust の場合は QuickJS のような非常に軽量な JS Runtime を組み込むだけで十分です。なお、ここでの JavaScript Runtime は標準の JS サブセットであり、Bun や Node のような完全な OS API を備えたランタイムとは全く異なり、本当に推論や derive を行うためだけのものです。
こうすることで、コンポーネント内に一定の計算ロジックを記述しておくことができ、このロジックはバックエンドでデータを取得した後に小さな JS を実行して ready-to-display の状態へ推導し、フロントエンドに送り返されてファーストビューのハイドレーションに使われます。これにより、厳密な構造を持つ派生データが得られて CTR も満足し、開発者の DX も向上し、コンポーネントを書くのは1回だけで済みます。もちろん、純粋な型制約を厳格に守るのであれば、バックエンドは確かに No JS Runtime を実現でき、この約束は実際には今でも成立しています。しかしどちらにせよ、新たに追加されたこの非常に小さな JavaScript Runtime は、Node などに比べてパフォーマンスオーバーヘッド、メモリ使用量、サイズが遥かに小さく、わずか 200-300KB 程度でありながら、非常に大きな柔軟性をもたらしてくれます。
今後の展望
色々言いましたが、いつになったら実際に使えるようになるの?:たぶん、まだまだずっと先の話になりそうです。別にドタキャンしたいわけではなく、現段階でこのプロダクトにあまりにも自分のリソースを奪われすぎていると感じているからです。フレームワーク自体の開発だけにしか使えない状態では、実用的なアプリはまともに書けません。何を書こうとしてもフレームワークに足を引っ張られますし、それにここ最近は主にこのウェブサイトの開発にかかりきりでした。だから思い直したんです。まずはこのサイトをある程度の規模まで作り込んで、自分が本当に必要としている機能が何なのかを把握しようと。その後に SeamJS の開発に戻れば TODO List ができているので、機能を一つずつ実装していけば使えるようになります。ついでに後から遷移と Benchmark の記事も一本水増しして書けるかも?さて、大風呂敷を広げるのはここまでにして、コンセプトは示しました。今日使えるかどうかは置いといて、少なくとも考え方のプロトタイプは通りました。おやすみなさい 💤