← ブログ

コンソールのサーバーレンダリング

Text.js 0.4はサーバーでページを描画し、フレームをセルの差分としてコンソールにストリーミングできます。静的ルートでは最初のフレームもHTMLに事前描画します。

Adam

Text.js 0.4では、ページをレンダリングする場所がもう1つ加わりました。デフォルトでは、ページのReactツリーはブラウザーのタブで実行されます。runtime = "server" をエクスポートするページはサーバーで実行され、ブラウザーはそのフレームを受信します。

app/status/page.tsxtsx
import { Suspense, type ReactNode } from "react";import os from "node:os";import { Box, Text } from "textjs";export const runtime = "server";async function Load(): Promise<ReactNode> {  const [one, five] = os.loadavg();  return <Text>{`load ${one.toFixed(2)} ${five.toFixed(2)}`}</Text>;}export default function StatusPage(): ReactNode {  return (    <Box flexDirection="column">      <Text bold>Status</Text>      <Suspense fallback={<Text color="gray">Reading host…</Text>}>        <Load />      </Suspense>    </Box>  );}

Load は async function コンポーネントです。サーバーでは、Promiseが解決するまで最も近い <Suspense> または loading.tsx でサスペンドし、ファイル、データベース、node:* モジュールを直接読み取れます。非同期コンポーネントはデータを読み込み、フックは使えません。その周囲の通常のコンポーネントでは、フック、フォーカス、useInput、<Link>、useRouter() を引き続き使え、どちらのランタイムでも同じように動作します。

ブラウザーがダウンロードするもの

サーバーレンダリングされるページでは、React、Yoga、アプリのルートは配信されません。ブラウザーはgzipで約21 kBのフレームプレーヤーを読み込みます。このプレーヤーは CellBuffer と、クライアントレンダラーと同じ CssConsoleDriver を保持します。同じ15fpsでフレームを送るため、サーバーレンダリングされたフレームでも、クライアントと同じ console.log 呼び出しが生成されます。

プロトコル

各タブはサーバー上に専用のセッションを開き、Reactルート、メモリー内のルーター履歴、セルバッファーを持ちます。1つのエンドポイントが双方向の通信を担います。

browser tab ──(GET /__textjs/live, server-sent events)──▶ frame deltasbrowser tab ──(POST /__textjs/live, JSON)──────────────▶ keys, paste, wheel, resize, focus, Back/Forward

サーバーは、その接続が最後に受信したフレーム以降にハッシュが変わった行だけを送信します。各行は、追記専用のスタイルテーブルを参照するテキストの連なりとして送られます。幅の広い書記素、絵文字クラスター、半透明の色もそのまま届きます。

フレームの間隔は少なくとも66msで、各フレームはソケットが最後に受け入れた内容との差分を取ります。低速な接続で送信待ちデータが流れなくなると、途中の状態はスキップされ、次のフレームで最新の状態に進みます。

ストリームが切断されると、プレーヤーは x-textjs-live ヘッダーにセッションIDを付けて再接続し、状態を保持したまま、新しいキーフレームから同じセッションを再開します。セッションIDは192ビットのランダムな値で、メモリー内に保持され、URLには入りません。接続のないセッションはレンダリングを停止し、30秒後にアンマウントされます。

両方のエンドポイントがクロスサイトリクエストを拒否し、すべての入力イベントを固定スキーマに照らして検証します。セッションが受け付けるイベントは、POSTあたり最大64件、1秒あたり約240件です。

非同期コンポーネントとデータ

各非同期コンポーネントのPromiseは、コンポーネントとそのpropsをキーとしてセッションごとにキャッシュされるため、レンダリングを再試行すると、すでに進行中のリクエストが再利用されます。ルートが変わったときと、router.refresh() を呼び出したときにキャッシュがクリアされます。

const router = useRouter();useInput((e) => e.input === "r" && router.refresh());

サーバー専用モジュールには *.server.ts という名前を付けます。ブラウザーに配信されるコードがこれをインポートした場合や、node:* の組み込みモジュールをインポートした場合、ビルドは失敗し、インポート元が表示されます。

部分プリレンダリング

部分プリレンダリングはサーバーなしで動作します。text build は各静的ルートを1回レンダリングし、最初のフレームを小さなクラシック <script> としてルートのHTMLに埋め込むようになったため、どのモジュールが読み込まれるよりも先にコンソールが描画されます。

シェルは、同期的にレンダリングされるすべての部分です。非同期コンポーネントを含め、まだ待機中の <Suspense> 境界はフォールバックを表示し、シェルのレンダリング中に fetch() が解決することはありません。

JavaScriptが起動すると、クライアントレンダラーまたはフレームプレーヤーが実際のフレームをストリーミングします。0.4ではクリアする代わりにフレームを追記するため、ハイドレーション中のレンダラーは initialClear: false で開始してシェルの下にログを出力し、そのカーテンが同じDevToolsの描画でシェルを表示領域の外へ移動させます。コンソールのクリアは、シェルによって正確に1回だけ実行されます。

 ✓ Prerendering console shells (5/5) · 1 server-rendered, frame player 56.5 kB (21.3 kB gzip)

ビルド時には訪問者のコンソールのサイズがわからないため、シェルは設定された viewport サイズでレンダリングされます。デフォルトは 120 × 36 です。viewport.responsive を有効にすると、最初のライブフレームがドッキングされたコンソールに合わせてサイズを変更します。動的ルートではシェルはレンダリングされず、text.config.ts で ppr: false を指定するとプリレンダリングが無効になります。

ランタイムの選択

クライアントランタイムは引き続きデフォルトです。キー入力は同じタスク内で描画され、アプリは任意の静的ホストにデプロイできます。サーバーランタイムは、非公開データを読み取るダッシュボードや管理画面、依存関係をブラウザーバンドルに含めたくないルートに適しています。1つのアプリで両方を混在させることができ、それらの間を移動すると、移動先のURLが新しいドキュメントとして読み込まれます。

詳しいリファレンスはサーバーレンダリングにあります。