···2525- [Error Handling](https://rscexplorer.dev/?s=errors)
2626- [Client Reference](https://rscexplorer.dev/?s=clientref)
2727- [Bound Actions](https://rscexplorer.dev/?s=bound)
2828+- [Binary Data](https://rscexplorer.dev/?s=binary)
2829- [Kitchen Sink](https://rscexplorer.dev/?s=kitchensink)
2930- [CVE-2025-55182](https://rscexplorer.dev/?s=cve)
3031···3940- The Server part runs in a worker.
4041- We try to approximate a real RSC environment as much as we can (while staying in browser).
4142- No dependencies on React internals. We use `react-server-dom-webpack` and shim the Webpack runtime.
4242-- No dependencies on the protocol format. We display it, but treat it as an implementation detail of React.
4343+- Minimal dependencies on the protocol format. We break the stream into rows, but treat them as an implementation detail of React.
4344- Only end-to-end tests.
44454546This is fully vibecoded but heavily steered so individual pieces may be weird or suboptimal.
···11+// React Flight Protocol Row Parser
22+//
33+// This is a framing parser that splits the RSC byte stream into discrete rows.
44+// It does NOT interpret row contents, it only determines row boundaries.
55+// (We want to keep knowledge of implementation details as little as possible.)
66+//
77+// Two framing modes based on tag byte after "ID:":
88+// - Binary framing: ID:TAG + HEX_LENGTH + "," + BINARY_DATA (no terminator)
99+// - Text framing: ID:TAG + DATA + "\n" (newline terminated)
1010+//
1111+// The parser must know which tags use binary framing to correctly find row
1212+// boundaries. If a binary tag is missing from BINARY_TAGS, the parser will
1313+// treat it as text, scan for newline, and corrupt subsequent parsing.
1414+1515+// Check if byte is a hex digit (0-9, a-f, A-F)
1616+function isHexDigit(b: number): boolean {
1717+ return (b >= 0x30 && b <= 0x39) || (b >= 0x61 && b <= 0x66) || (b >= 0x41 && b <= 0x46);
1818+}
1919+2020+// Parse hex digit to value (0-15)
2121+function hexValue(b: number): number {
2222+ if (b >= 0x30 && b <= 0x39) return b - 0x30; // 0-9
2323+ if (b >= 0x61 && b <= 0x66) return b - 0x57; // a-f
2424+ return b - 0x37; // A-F
2525+}
2626+2727+// Tags that use binary (length-prefixed) framing
2828+const BINARY_TAGS = new Set([
2929+ 0x54, // T - long text
3030+ 0x41, // A - ArrayBuffer
3131+ 0x4f, // O - Int8Array
3232+ 0x6f, // o - Uint8Array
3333+ 0x55, // U - Uint8ClampedArray
3434+ 0x53, // S - Int16Array
3535+ 0x73, // s - Uint16Array
3636+ 0x4c, // L - Int32Array
3737+ 0x6c, // l - Uint32Array
3838+ 0x47, // G - Float32Array
3939+ 0x67, // g - Float64Array
4040+ 0x4d, // M - BigInt64Array
4141+ 0x6d, // m - BigUint64Array
4242+ 0x56, // V - DataView
4343+ 0x62, // b - byte stream chunk
4444+]);
4545+4646+export type RowSegment = { type: "text" | "binary"; data: Uint8Array };
4747+4848+export interface ParsedRow {
4949+ id: string;
5050+ segment: RowSegment;
5151+ raw: Uint8Array;
5252+}
5353+5454+export interface ParseResult {
5555+ rows: ParsedRow[];
5656+ remainder: Uint8Array;
5757+}
5858+5959+export function parseRows(buffer: Uint8Array, final: boolean = false): ParseResult {
6060+ const rows: ParsedRow[] = [];
6161+ let i = 0;
6262+6363+ while (i < buffer.length) {
6464+ const rowStart = i;
6565+6666+ // Row ID: hex digits until ':'
6767+ while (i < buffer.length && buffer[i] !== 0x3a) {
6868+ const b = buffer[i]!;
6969+ if (!isHexDigit(b)) {
7070+ throw new Error(`Expected hex digit in row ID, got 0x${b.toString(16)}`);
7171+ }
7272+ i++;
7373+ }
7474+ if (i >= buffer.length) {
7575+ if (final) {
7676+ throw new Error(`Truncated row ID at end of stream`);
7777+ }
7878+ return { rows, remainder: buffer.slice(rowStart) };
7979+ }
8080+8181+ const id = decodeAscii(buffer, rowStart, i);
8282+ // buffer[i] is guaranteed to be colon here (while loop exit condition)
8383+ i++;
8484+8585+ if (i >= buffer.length) {
8686+ if (final) {
8787+ throw new Error(`Row ${id} truncated after colon`);
8888+ }
8989+ return { rows, remainder: buffer.slice(rowStart) };
9090+ }
9191+9292+ const tag = buffer[i]!;
9393+9494+ if (BINARY_TAGS.has(tag)) {
9595+ // Binary framing: TAG + HEX_LENGTH + "," + DATA
9696+ i++;
9797+9898+ let length = 0;
9999+ while (i < buffer.length && buffer[i] !== 0x2c) {
100100+ const b = buffer[i]!;
101101+ if (!isHexDigit(b)) {
102102+ throw new Error(
103103+ `Expected hex digit in binary length for row ${id}, got 0x${b.toString(16)}`,
104104+ );
105105+ }
106106+ length = (length << 4) | hexValue(b);
107107+ i++;
108108+ }
109109+110110+ if (i >= buffer.length) {
111111+ if (final) {
112112+ throw new Error(`Row ${id} truncated in binary length`);
113113+ }
114114+ return { rows, remainder: buffer.slice(rowStart) };
115115+ }
116116+ // buffer[i] is guaranteed to be comma here (while loop exit condition)
117117+ i++;
118118+119119+ if (i + length > buffer.length) {
120120+ if (final) {
121121+ throw new Error(
122122+ `Row ${id} truncated in binary data (need ${length} bytes, have ${buffer.length - i})`,
123123+ );
124124+ }
125125+ return { rows, remainder: buffer.slice(rowStart) };
126126+ }
127127+128128+ const data = buffer.slice(i, i + length);
129129+ const raw = buffer.slice(rowStart, i + length);
130130+ rows.push({ id, segment: { type: "binary", data }, raw });
131131+ i += length;
132132+ } else {
133133+ // Text framing: scan for newline
134134+ const contentStart = i + 1; // after the tag byte
135135+ while (i < buffer.length && buffer[i] !== 0x0a) {
136136+ i++;
137137+ }
138138+139139+ if (i >= buffer.length) {
140140+ if (!final) {
141141+ // Incomplete row, wait for more data
142142+ return { rows, remainder: buffer.slice(rowStart) };
143143+ }
144144+ throw new Error(`Text row ${id} missing trailing newline at end of stream`);
145145+ } else {
146146+ const data = buffer.slice(contentStart, i);
147147+ const raw = buffer.slice(rowStart, i + 1);
148148+ rows.push({ id, segment: { type: "text", data }, raw });
149149+ i++;
150150+ }
151151+ }
152152+ }
153153+154154+ return { rows, remainder: new Uint8Array(0) };
155155+}
156156+157157+function decodeAscii(buffer: Uint8Array, start: number, end: number): string {
158158+ let s = "";
159159+ for (let i = start; i < end; i++) {
160160+ s += String.fromCharCode(buffer[i]!);
161161+ }
162162+ return s;
163163+}
+2-1
src/client/runtime/index.ts
···55 type Thenable,
66 type CallServerCallback,
77} from "./steppable-stream.ts";
88-export { Timeline, type EntryView, type TimelineSnapshot } from "./timeline.ts";
88+export { Timeline, type EntryView, type RowView, type TimelineSnapshot } from "./timeline.ts";
99+export { parseRows, type ParsedRow, type ParseResult, type RowSegment } from "./flight-parser.ts";