1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

React の Flight Protocol って何?

Posted at

現在、React2Shell(CVE-2025-55182) で世間は大騒ぎかと思います。

自分もこれについて理解のために調査を行っていますが、「そもそも Flight Protocol ってなんだ?」という点を必ず通ると思います。
そのため、個人的な理解ではありますが、理解した内容をまとめます。

Flight Protocol とは

React Server Component(RSC) で利用されている独自のシリアライズフォーマットです。React 18 で導入され、Next.js では App Router で活用されています。
クライアントとサーバーの間で React Compoenent を含むデータを効率的に転送するために設計され、Promise など非常に複雑多様な型を取り扱えます。

特徴として、chunk と呼ばれる単位に分けられ、この chunk は相互参照可能になっています。それぞれが Promise-like なオブジェクトで、pending/fulfilled といったステータスを持ち、内部的には Promise.prototype を継承しています。また、$ を接頭辞とする特殊な記号を用いて複雑な処理が可能です。

具体的には Next.js の Server Actions を実装する際に役立ちます。

歴史的な経緯

従来の React において、クライアントサイドにおけるデータフェッチに伴い、バンドルサイズの増加やネットワークのウォーターフォールが発生していました。
これの解決のため、レンダリングのみならずデータアクセスについてもサーバー側で行うよう RSC が提案され、Flight Protocol はそのシリアライズ手法として生まれたものと思われます。

Flight Protocol を観測する

本来は Server Actions により「クライアントが受け取った HTML に JS が含まれない」という状況を作り出し、JS 非対応環境でも動作させるようにする、といったことが利点としてあります。
ただ、ここでは Flight Protocol の観測がしたいので、上記を潰して下記のような簡単な検証環境を作りました。

バージョン

この設定は React2Shell に対して脆弱な状態です。同じ設定にする際はお気をつけて。

  • Next.js: 16.0.6
  • React: 19.2.0

ソースコード

app/page.tsx
'use client';
import { updateUserData } from "./actions";
import { useState } from "react";

export default function Home() {

  const [name, setName] = useState("Guest");
  const [email, setEmail] = useState("anonymous@example.com");

  const onSubmit = async () => {
    const newName = (document.querySelector('input[name="name"]') as HTMLInputElement).value;
    const newEmail = (document.querySelector('input[name="email"]') as HTMLInputElement).value;
    const res = await updateUserData(newName, newEmail);
    setName(res.name);
    setEmail(res.email);
    console.log("User data updated on client:", res);
  }

  return (
    <html>
      <body>
        <div>
          <h1>Welcome to the Sample Page</h1>
          <h2>{"Your Name: " + name}</h2>
          <h2>{"Your Email: " + email}</h2>

          <form action={onSubmit}>
            <input type="text" name="name" placeholder="Enter your name" defaultValue={name} required />
            <input type="email" name="email" placeholder="Enter your email" defaultValue={email} required />
            <button type="submit">Update Info</button>
          </form>
        </div>
      </body>
    </html>
  );
}
app/actions.tsx
'use server';

export async function updateUserData(name: string, email: string) {
    console.log("Updating user data:", name, email);

    return { name, email }
}

この状態で、下記を確認します(Chrome の開発者モードを利用します)

  1. クライアント→サーバーとしてどのようなリクエストが送られるか
  2. サーバー→クライアントのレスポンスはどのようなものか

リクエスト

結構独自 header がありつつも、送られる内容はシンプルでした。Cookie 周りに開発環境がごちゃごちゃになって生まれた関係ないものが混ざってますが、気にしなくてOKです。
スクリーンショット 2025-12-15 235619.png
スクリーンショット 2025-12-15 235600.png

気になった点としては、下記でしょう。

  • text/x-component のやり取り。ただ、平文として送っているように見えます。
  • 下記の独自ヘッダー達
    • next-action: どのサーバーアクションを行うべきか。この ID をサーバー/クライアントで共有することで、行うべきアクションを指定することが可能です。
    • next-router-state-tree: パフォーマンス向上のため、現在のルーティング状態をサーバーに送信しているようです。参考

    partial rendering relies on the next-router-state-tree header from the client to decide if the server should respond with the shared layout data, or only the page data, to avoid over-fetching and improve performance.

    • x-nextjs-html-request-id: 謎(気にする必要は無いはずではある)
    • x-next-js-request-id: 謎(気にする必要は無いはずではある)

レスポンス

:N1765810143206.2812
0:{"a":"$@1","f":"","b":"development"}
1:D{"time":0.3073199999053031}
1:{"name":"Guest","email":"anonymous@example.com"}

これが噂の Flight Protocol ですね。3 つの chunk からなるように見受けられます。
重要な点として、リクエスト時ではなく、レスポンス時に評価されている気がします?React2Shellって、「リクエスト内容がそのままレスポンスに反映されて、それがエスケープされずに Flight Request として取り扱われてしまった」ことが原因の可能性、あるのかもしれません。


Server Actions のセキュリティについては、下記も参考になりそうです(執筆中に見つけました)
https://note.com/offers_deepdive/n/n7620046e7c62#5b3a1181-a868-426e-a153-464c76c03606

Flight Protocol における "$"

Flight Protocol にはほぼほぼ $ という特殊文字が登場します。下記のような意味を持ちます。
量が非常に多く、生成 AI に作成してもらったものをそのまま掲示します。ただし、内容については大まかに合っていることを確認していて、ざっくりとした理解にご利用いただけると幸いです。

表記 意味
\$\$ \$\$hello エスケープされた通常文字列。で始まるリテラル文字列を特殊扱いから逃れる。デシリアライズ時にvalue.slice(1)で余分なで始まるリテラル文字列を特殊扱いから逃れる。デシリアライズ時にvalue.slice(1)で余分なで始まるリテラル文字列を特殊扱いから逃れる。デシリアライズ時にvalue.slice(1)で余分なを削除し、元の文字列(例: \$hello)を返す。
\$@ \$@1 PromiseまたはChunk参照。@以降の16進IDをparseIntで取得し、getChunkでChunkオブジェクトを返す。非同期値のストリーミングをサポート。
\$F \$Fabc Server Reference(サーバー関数)。F以降のrefをgetOutlinedModelでメタデータを取得し、loadServerReferenceで関数をロード。サーバーアクションの呼び出しに使用。
\$T \$T123:0 Temporary Reference(一時参照)。response._temporaryReferencesからcreateTemporaryReferenceでオブジェクトを作成。referenceが未定義の場合エラー。トランジェントデータを扱う。
\$Q \$Qdef Mapオブジェクト。Q以降のrefをgetOutlinedModelで取得し、createMapでMapインスタンスを構築。JSON非対応のコレクションをシリアライズ。
\$W \$Wxyz Setオブジェクト。W以降のrefをgetOutlinedModelで取得し、createSetでSetインスタンスを構築。
\$K \$K456 FormDataオブジェクト。K以降のstringIdでプレフィックス(prefix + stringId + '')を作成し、_formDataからエントリを収集してFormDataを構築。フォーム/バイナリデータを扱う。
\$i \$ighi Iterator。i以降のrefをgetOutlinedModelで取得し、extractIteratorでイテレータを抽出。イテラブルデータをサポート。
\$I \$I Infinity値。グローバルInfinityを返す。
\$-0 \$\$ -0 負のゼロ値。value === ' \$\$-0' で-0を返す。特殊数値を区別。
\$- (例: \$-I) \$\$ -I -Infinity値。 \$\$-0以外の場合、-Infinityを返す(コードではelseで一律-Infinity)。
\$N \$N NaN値。グローバルNaNを返す。
\$u \$u undefined値。グローバルundefinedを返す(\$undefinedとしてもマッチ)。欠損値を表現。
\$D \$D2023-01-01T00:00:00Z Dateオブジェクト。D以降の文字列をDate.parseでパースし、Dateインスタンスを返す。
\$n \$n123 BigInt値。n以降の文字列をBigIntコンストラクタで変換。
\$A \$A789 ArrayBuffer。parseTypedArrayでBlobからバッファを作成(バイト長1)。TypedArrayのベースバッファ。
\$O \$Oabc Int8Array。parseTypedArrayでInt8Arrayを作成(バイト長1)。
\$o \$o123 Uint8Array。parseTypedArrayでUint8Arrayを作成(バイト長1)。
\$U \$U456 Uint8ClampedArray。parseTypedArrayでUint8ClampedArrayを作成(バイト長1)。
\$S \$S789 Int16Array。parseTypedArrayでInt16Arrayを作成(バイト長2)。
\$s \$sabc Uint16Array。parseTypedArrayでUint16Arrayを作成(バイト長2)。
\$L \$L123 Int32Array。parseTypedArrayでInt32Arrayを作成(バイト長4)。
\$l \$l456 Uint32Array。parseTypedArrayでUint32Arrayを作成(バイト長4)。
\$G \$G789 Float32Array。parseTypedArrayでFloat32Arrayを作成(バイト長4)。
\$g \$g123 Float64Array。parseTypedArrayでFloat64Arrayを作成(バイト長8)。
\$M \$M456 BigInt64Array。parseTypedArrayでBigInt64Arrayを作成(バイト長8)。
\$m \$m789 BigUint64Array。parseTypedArrayでBigUint64Arrayを作成(バイト長8)。
\$V \$Vabc DataView。parseTypedArrayでDataViewを作成(バイト長1)。バッファビュー。
\$B \$Babc Blob参照。B以降の16進IDをparseIntで取得し、_formDataからプレフィックスキー(_prefix + id)でBlobを取得。バイナリデータを扱う。
\$R \$R123 ReadableStream(デフォルト)。parseReadableStreamでストリームを作成。ストリーミングデータをサポート。
\$r \$r456 ReadableStream(bytes型)。parseReadableStreamでバイトストリームを作成。
\$X \$X789 AsyncIterable。parseAsyncIterableで非同期イテラブルを作成(iterator: false)。
\$x \$xabc AsyncIterator。parseAsyncIterableで非同期イテレータを作成(iterator: true)。
\$ (デフォルト: id or id:path) \$1 または \$1:proto:then 参照ID(Outlined Model)。value.slice(1)をrefとし、getOutlinedModelで解決。単一IDはChunk value、:区切りpathはオブジェクト走査(例: value[path[0]][path[1]]...)。ツリー構造の効率化に使用。

多くの関数で getOutlinedModel という関数を呼び出しており、他の chunk の結果を参照しながら、それを Map や Set、一時参照などに変換する、という感じがしています。ただ、ちょっとソースコードの読み込みが甘くて、$Q123:123 といった値でどのような挙動をするかは未検証です。

また、parseTypedArray_formData.get(_prefix + <ref: $Rxxxのxxxの部分>) を指定した型に変換します。
parsedReadableStream/parseAsyncIterable に関してはちょっと謎めいた挙動をしているので、分かり次第追記するかもです(どちらも似た挙動ではあるので、しっかり追えれば理解できそう)

Flight Request の挙動を手でシミュレーションしてみる

ちょうど先ほど好例があったので、試してみましょう。

:N1765810143206.2812
0:{"a":"$@1","f":"","b":"development"}
1:D{"time":0.3073199999053031}
1:{"name":"Guest","email":"anonymous@example.com"}

最初にキーが 0 のオブジェクトが解釈されます。

0:{"a":"$@1","f":"","b":"development"}

ただ、$@1 が含まれており、評価しきれません。ちょっと置いておきましょう。

次に 1 のオブジェクトを評価します。

1:{"name":"Guest","email":"anonymous@example.com"}

これは評価可能です。resolve されるタイミングで、0 のオブジェクトの評価が遅延処理で返ってきます。

0:{"a":{"name":"Guest","email":"anonymous@example.com"},"f":"","b":"development"}

いい感じになりました。残った下記ですが、恐らく chunk の評価時間だと思われます。(若干調査中。もし違ったら変更します)

1:D{"time":0.3073199999053031}
1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?