A tool for people curious about the React Server Components protocol rscexplorer.dev/
rsc react
at main 163 lines 4.9 kB view raw
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}