A tool for people curious about the React Server Components protocol
rscexplorer.dev/
rsc
react
1// React Flight Protocol Row Parser
2//
3// This is a framing parser that splits the RSC byte stream into discrete rows.
4// It does NOT interpret row contents, it only determines row boundaries.
5// (We want to keep knowledge of implementation details as little as possible.)
6//
7// Two framing modes based on tag byte after "ID:":
8// - Binary framing: ID:TAG + HEX_LENGTH + "," + BINARY_DATA (no terminator)
9// - Text framing: ID:TAG + DATA + "\n" (newline terminated)
10//
11// The parser must know which tags use binary framing to correctly find row
12// boundaries. If a binary tag is missing from BINARY_TAGS, the parser will
13// treat it as text, scan for newline, and corrupt subsequent parsing.
14
15// Check if byte is a hex digit (0-9, a-f, A-F)
16function isHexDigit(b: number): boolean {
17 return (b >= 0x30 && b <= 0x39) || (b >= 0x61 && b <= 0x66) || (b >= 0x41 && b <= 0x46);
18}
19
20// Parse hex digit to value (0-15)
21function hexValue(b: number): number {
22 if (b >= 0x30 && b <= 0x39) return b - 0x30; // 0-9
23 if (b >= 0x61 && b <= 0x66) return b - 0x57; // a-f
24 return b - 0x37; // A-F
25}
26
27// Tags that use binary (length-prefixed) framing
28const BINARY_TAGS = new Set([
29 0x54, // T - long text
30 0x41, // A - ArrayBuffer
31 0x4f, // O - Int8Array
32 0x6f, // o - Uint8Array
33 0x55, // U - Uint8ClampedArray
34 0x53, // S - Int16Array
35 0x73, // s - Uint16Array
36 0x4c, // L - Int32Array
37 0x6c, // l - Uint32Array
38 0x47, // G - Float32Array
39 0x67, // g - Float64Array
40 0x4d, // M - BigInt64Array
41 0x6d, // m - BigUint64Array
42 0x56, // V - DataView
43 0x62, // b - byte stream chunk
44]);
45
46export type RowSegment = { type: "text" | "binary"; data: Uint8Array };
47
48export interface ParsedRow {
49 id: string;
50 segment: RowSegment;
51 raw: Uint8Array;
52}
53
54export interface ParseResult {
55 rows: ParsedRow[];
56 remainder: Uint8Array;
57}
58
59export function parseRows(buffer: Uint8Array, final: boolean = false): ParseResult {
60 const rows: ParsedRow[] = [];
61 let i = 0;
62
63 while (i < buffer.length) {
64 const rowStart = i;
65
66 // Row ID: hex digits until ':'
67 while (i < buffer.length && buffer[i] !== 0x3a) {
68 const b = buffer[i]!;
69 if (!isHexDigit(b)) {
70 throw new Error(`Expected hex digit in row ID, got 0x${b.toString(16)}`);
71 }
72 i++;
73 }
74 if (i >= buffer.length) {
75 if (final) {
76 throw new Error(`Truncated row ID at end of stream`);
77 }
78 return { rows, remainder: buffer.slice(rowStart) };
79 }
80
81 const id = decodeAscii(buffer, rowStart, i);
82 // buffer[i] is guaranteed to be colon here (while loop exit condition)
83 i++;
84
85 if (i >= buffer.length) {
86 if (final) {
87 throw new Error(`Row ${id} truncated after colon`);
88 }
89 return { rows, remainder: buffer.slice(rowStart) };
90 }
91
92 const tag = buffer[i]!;
93
94 if (BINARY_TAGS.has(tag)) {
95 // Binary framing: TAG + HEX_LENGTH + "," + DATA
96 i++;
97
98 let length = 0;
99 while (i < buffer.length && buffer[i] !== 0x2c) {
100 const b = buffer[i]!;
101 if (!isHexDigit(b)) {
102 throw new Error(
103 `Expected hex digit in binary length for row ${id}, got 0x${b.toString(16)}`,
104 );
105 }
106 length = (length << 4) | hexValue(b);
107 i++;
108 }
109
110 if (i >= buffer.length) {
111 if (final) {
112 throw new Error(`Row ${id} truncated in binary length`);
113 }
114 return { rows, remainder: buffer.slice(rowStart) };
115 }
116 // buffer[i] is guaranteed to be comma here (while loop exit condition)
117 i++;
118
119 if (i + length > buffer.length) {
120 if (final) {
121 throw new Error(
122 `Row ${id} truncated in binary data (need ${length} bytes, have ${buffer.length - i})`,
123 );
124 }
125 return { rows, remainder: buffer.slice(rowStart) };
126 }
127
128 const data = buffer.slice(i, i + length);
129 const raw = buffer.slice(rowStart, i + length);
130 rows.push({ id, segment: { type: "binary", data }, raw });
131 i += length;
132 } else {
133 // Text framing: scan for newline
134 const contentStart = i + 1; // after the tag byte
135 while (i < buffer.length && buffer[i] !== 0x0a) {
136 i++;
137 }
138
139 if (i >= buffer.length) {
140 if (!final) {
141 // Incomplete row, wait for more data
142 return { rows, remainder: buffer.slice(rowStart) };
143 }
144 throw new Error(`Text row ${id} missing trailing newline at end of stream`);
145 } else {
146 const data = buffer.slice(contentStart, i);
147 const raw = buffer.slice(rowStart, i + 1);
148 rows.push({ id, segment: { type: "text", data }, raw });
149 i++;
150 }
151 }
152 }
153
154 return { rows, remainder: new Uint8Array(0) };
155}
156
157function decodeAscii(buffer: Uint8Array, start: number, end: number): string {
158 let s = "";
159 for (let i = start; i < end; i++) {
160 s += String.fromCharCode(buffer[i]!);
161 }
162 return s;
163}