@@ -38,8 +38,9 @@ import { PassThrough } from 'stream';
3838import streamServerRenderedReactComponent from '../src/streamServerRenderedReactComponent.ts' ;
3939import * as ComponentRegistry from '../src/ComponentRegistry.ts' ;
4040import ReactOnRails from '../src/ReactOnRails.node.ts' ;
41+ import LengthPrefixedStreamParser from '../src/parseLengthPrefixedStream.ts' ;
4142
42- const HIGHWATER_MARK = 16 * 1024 ; // Node.js default PassThrough highWaterMark: 16KB
43+ const INCOMPLETE_LENGTH_PREFIXED_STREAM_WARNING = '[react_on_rails] Incomplete length-prefixed stream' ;
4344
4445const testingRailsContext = {
4546 serverSideRSCPayloadParameters : { } ,
@@ -48,37 +49,129 @@ const testingRailsContext = {
4849 componentSpecificMetadata : {
4950 renderRequestId : '123' ,
5051 } ,
51- } as any ;
52+ } ;
5253
53- // Collect all JSON chunks from the result stream into parsed objects.
54- // streamServerRenderedReactComponent emits JSON objects: {html, consoleReplayScript, hasErrors, isShellReady}
55- const collectChunks = (
56- stream : NodeJS . ReadableStream ,
57- ) : Promise < { html : string ; hasErrors : boolean ; isShellReady : boolean } [ ] > =>
54+ type RailsContextWithRSCPayloadStream = typeof testingRailsContext & {
55+ getRSCPayloadStream : (
56+ componentName : string ,
57+ props : Record < string , unknown > ,
58+ ) => Promise < AsyncIterable < Buffer > > ;
59+ } ;
60+
61+ const toLengthPrefixedPayload = ( content : string ) : Buffer => {
62+ const contentBuffer = Buffer . from ( content , 'utf8' ) ;
63+ const metadata = JSON . stringify ( { consoleReplayScript : '' , hasErrors : false , isShellReady : true } ) ;
64+ return Buffer . concat ( [
65+ Buffer . from ( `${ metadata } \t${ contentBuffer . length . toString ( 16 ) . padStart ( 8 , '0' ) } \n` , 'utf8' ) ,
66+ contentBuffer ,
67+ ] ) ;
68+ } ;
69+
70+ const expectRSCPayloadPushScript = ( html : string ) => {
71+ expect ( html ) . toMatch ( / R E A C T _ O N _ R A I L S _ R S C _ P A Y L O A D S [ ^ < ] * \. p u s h \( / ) ;
72+ } ;
73+
74+ type StreamResultChunk = {
75+ html : string ;
76+ consoleReplayScript : string ;
77+ hasErrors : boolean ;
78+ isShellReady : boolean ;
79+ } ;
80+
81+ // Collect all length-prefixed chunks from the result stream into parsed objects.
82+ // streamServerRenderedReactComponent emits: <metadata JSON>\t<content byte length hex>\n<raw html bytes>
83+ const collectChunks = ( stream : NodeJS . ReadableStream ) : Promise < StreamResultChunk [ ] > =>
5884 new Promise ( ( resolve , reject ) => {
59- const chunks : { html : string ; hasErrors : boolean ; isShellReady : boolean } [ ] = [ ] ;
85+ const chunks : StreamResultChunk [ ] = [ ] ;
86+ const parser = new LengthPrefixedStreamParser ( ) ;
87+ const decoder = new TextDecoder ( ) ;
88+
89+ const flushParserOrThrow = ( ) => {
90+ // Capture warnings manually to preserve any outer console.warn spy after flush().
91+ const warnings : unknown [ ] [ ] = [ ] ;
92+ const originalWarn = console . warn ;
93+ console . warn = ( ...args ) => {
94+ warnings . push ( args ) ;
95+ } ;
96+
97+ try {
98+ parser . flush ( ) ;
99+ const incompleteStreamWarning = warnings . find ( ( [ message ] ) =>
100+ String ( message ) . includes ( INCOMPLETE_LENGTH_PREFIXED_STREAM_WARNING ) ,
101+ ) ;
102+
103+ if ( incompleteStreamWarning ) {
104+ throw new Error ( String ( incompleteStreamWarning [ 0 ] ) ) ;
105+ }
106+ } finally {
107+ // flush() is synchronous, so restoring here preserves any outer spy safely.
108+ console . warn = originalWarn ;
109+ }
110+ } ;
111+
60112 stream . on ( 'data' , ( chunk : Buffer ) => {
61- const text = new TextDecoder ( ) . decode ( chunk ) ;
62- // A single data event may contain multiple JSON objects separated by newlines
63- for ( const line of text . split ( '\n' ) . filter ( Boolean ) ) {
64- chunks . push ( JSON . parse ( line ) ) ;
113+ try {
114+ parser . feed ( chunk , ( content , metadata ) => {
115+ chunks . push ( {
116+ html : decoder . decode ( content ) ,
117+ ...metadata ,
118+ } as StreamResultChunk ) ;
119+ } ) ;
120+ } catch ( error ) {
121+ reject ( error instanceof Error ? error : new Error ( String ( error ) ) ) ;
122+ }
123+ } ) ;
124+ stream . on ( 'end' , ( ) => {
125+ try {
126+ flushParserOrThrow ( ) ;
127+ resolve ( chunks ) ;
128+ } catch ( error ) {
129+ reject ( error instanceof Error ? error : new Error ( String ( error ) ) ) ;
65130 }
66131 } ) ;
67- stream . on ( 'end' , ( ) => resolve ( chunks ) ) ;
68132 stream . on ( 'error' , reject ) ;
69133 } ) ;
70134
135+ describe ( 'collectChunks - length-prefixed stream parsing' , ( ) => {
136+ it ( 'preserves an outer console.warn spy while checking parser flush warnings' , async ( ) => {
137+ const completeStream = new PassThrough ( ) ;
138+ const consoleWarnSpy = jest . spyOn ( console , 'warn' ) . mockImplementation ( ( ) => undefined ) ;
139+ const result = collectChunks ( completeStream ) ;
140+
141+ try {
142+ completeStream . push ( toLengthPrefixedPayload ( 'complete payload' ) ) ;
143+ completeStream . push ( null ) ;
144+
145+ await expect ( result ) . resolves . toHaveLength ( 1 ) ;
146+ expect ( jest . isMockFunction ( console . warn ) ) . toBe ( true ) ;
147+ expect ( consoleWarnSpy ) . not . toHaveBeenCalledWith (
148+ expect . stringContaining ( INCOMPLETE_LENGTH_PREFIXED_STREAM_WARNING ) ,
149+ ) ;
150+ } finally {
151+ consoleWarnSpy . mockRestore ( ) ;
152+ }
153+ } ) ;
154+
155+ it ( 'rejects if the stream ends with an incomplete length-prefixed chunk' , async ( ) => {
156+ const truncatedStream = new PassThrough ( ) ;
157+ const fullChunk = toLengthPrefixedPayload ( 'truncated payload' ) ;
158+ const result = collectChunks ( truncatedStream ) ;
159+
160+ truncatedStream . push ( fullChunk . subarray ( 0 , fullChunk . length - 1 ) ) ;
161+ truncatedStream . push ( null ) ;
162+
163+ await expect ( result ) . rejects . toThrow ( INCOMPLETE_LENGTH_PREFIXED_STREAM_WARNING ) ;
164+ } ) ;
165+ } ) ;
166+
71167describe ( 'streamServerRenderedReactComponent - RSC payload exceeding default highWaterMark (e2e)' , ( ) => {
72168 let source : PassThrough ;
169+ let generateRSCPayload : jest . Mock < Promise < PassThrough > , [ string , unknown , unknown ] > ;
73170
74171 beforeEach ( ( ) => {
75172 ComponentRegistry . clear ( ) ;
76173 source = new PassThrough ( ) ;
77- ( globalThis as any ) . generateRSCPayload = jest . fn ( ) . mockResolvedValue ( source ) ;
78- } ) ;
79-
80- afterEach ( ( ) => {
81- delete ( globalThis as any ) . generateRSCPayload ;
174+ generateRSCPayload = jest . fn ( ) . mockResolvedValue ( source ) ;
82175 } ) ;
83176
84177 const renderComponent = ( name : string ) =>
@@ -89,7 +182,8 @@ describe('streamServerRenderedReactComponent - RSC payload exceeding default hig
89182 props : { } ,
90183 throwJsErrors : true ,
91184 railsContext : testingRailsContext ,
92- } as any ) ;
185+ generateRSCPayload,
186+ } as unknown as Parameters < typeof streamServerRenderedReactComponent > [ 0 ] ) ;
93187
94188 // Helper: register a render function whose returned Promise reads ALL data from the RSC
95189 // payload stream before resolving to a React element. This simulates what
@@ -101,7 +195,7 @@ describe('streamServerRenderedReactComponent - RSC payload exceeding default hig
101195 // that triggers backpressure issues when the payload exceeds stream2's buffer capacity.
102196 const registerRSCRenderFunction = ( name : string ) => {
103197 ReactOnRails . register ( {
104- [ name ] : ( _props : Record < string , unknown > , railsContext : any ) =>
198+ [ name ] : ( _props : Record < string , unknown > , railsContext : RailsContextWithRSCPayloadStream ) =>
105199 railsContext
106200 . getRSCPayloadStream ( 'ServerComponent' , _props )
107201 . then ( async ( rscStream : AsyncIterable < Buffer > ) => {
@@ -117,14 +211,14 @@ describe('streamServerRenderedReactComponent - RSC payload exceeding default hig
117211 `RSC payload: ${ totalBytes } bytes` ,
118212 ) ;
119213 } ) ,
120- } ) ;
214+ } as unknown as Parameters < typeof ReactOnRails . register > [ 0 ] ) ;
121215 } ;
122216
123217 it ( 'completes with RSC payload scripts for payloads under the default highWaterMark' , async ( ) => {
124218 registerRSCRenderFunction ( 'SmallRSCComponent' ) ;
125219
126220 // Push a small payload — fits within stream2's buffer, no backpressure risk
127- const payload = 'x' . repeat ( 1024 ) ;
221+ const payload = toLengthPrefixedPayload ( 'x' . repeat ( 1024 ) ) ;
128222 source . push ( payload ) ;
129223 source . push ( null ) ;
130224
@@ -135,11 +229,12 @@ describe('streamServerRenderedReactComponent - RSC payload exceeding default hig
135229
136230 // Verify the component rendered with the RSC data
137231 expect ( allHtml ) . toContain ( 'rsc-content' ) ;
138- expect ( allHtml ) . toContain ( 'RSC payload: 1024 bytes' ) ;
232+ // payload.length includes the length-prefix framing overhead (header + hex + newline + content)
233+ expect ( allHtml ) . toContain ( `RSC payload: ${ payload . length } bytes` ) ;
139234
140235 // Verify RSC payload initialization and data scripts are embedded
141236 expect ( allHtml ) . toContain ( 'REACT_ON_RAILS_RSC_PAYLOADS' ) ;
142- expect ( allHtml ) . toContain ( '.push(' ) ;
237+ expectRSCPayloadPushScript ( allHtml ) ;
143238 } ) ;
144239
145240 // Tests the edge case where the RSC Flight payload significantly exceeds the default
@@ -155,9 +250,9 @@ describe('streamServerRenderedReactComponent - RSC payload exceeding default hig
155250 // (16KB readable + 16KB writable).
156251 const chunkSize = 1024 ;
157252 const chunkCount = 128 ;
158- const totalBytes = chunkSize * chunkCount ;
159- const chunk = Buffer . alloc ( chunkSize , 0x61 ) ; // fill with 'a'
160- for ( let i = 0 ; i < chunkCount ; i ++ ) {
253+ const chunk = toLengthPrefixedPayload ( 'a' . repeat ( chunkSize ) ) ;
254+ const totalBytes = chunk . length * chunkCount ;
255+ for ( let i = 0 ; i < chunkCount ; i += 1 ) {
161256 source . push ( chunk ) ;
162257 }
163258 source . push ( null ) ;
@@ -170,7 +265,7 @@ describe('streamServerRenderedReactComponent - RSC payload exceeding default hig
170265 expect ( allHtml ) . toContain ( 'rsc-content' ) ;
171266 expect ( allHtml ) . toContain ( `RSC payload: ${ totalBytes } bytes` ) ;
172267 expect ( allHtml ) . toContain ( 'REACT_ON_RAILS_RSC_PAYLOADS' ) ;
173- expect ( allHtml ) . toContain ( '.push(' ) ;
268+ expectRSCPayloadPushScript ( allHtml ) ;
174269 } , 5000 ) ;
175270
176271 // Same as above but with data pushed asynchronously — more closely simulates a real
@@ -185,8 +280,8 @@ describe('streamServerRenderedReactComponent - RSC payload exceeding default hig
185280 // with React rendering and event loop ticks.
186281 const chunkSize = 1024 ;
187282 const chunkCount = 128 ;
188- const totalBytes = chunkSize * chunkCount ;
189- const chunk = Buffer . alloc ( chunkSize , 0x62 ) ; // fill with 'b'
283+ const chunk = toLengthPrefixedPayload ( 'b' . repeat ( chunkSize ) ) ;
284+ const totalBytes = chunk . length * chunkCount ;
190285 let pushed = 0 ;
191286 const pushInterval = setInterval ( ( ) => {
192287 if ( pushed >= chunkCount ) {
@@ -195,7 +290,7 @@ describe('streamServerRenderedReactComponent - RSC payload exceeding default hig
195290 return ;
196291 }
197292 source . push ( chunk ) ;
198- pushed ++ ;
293+ pushed += 1 ;
199294 } , 1 ) ;
200295
201296 const chunks = await collectChunks ( renderResult ) ;
@@ -204,6 +299,7 @@ describe('streamServerRenderedReactComponent - RSC payload exceeding default hig
204299 expect ( allHtml ) . toContain ( 'rsc-content' ) ;
205300 expect ( allHtml ) . toContain ( `RSC payload: ${ totalBytes } bytes` ) ;
206301 expect ( allHtml ) . toContain ( 'REACT_ON_RAILS_RSC_PAYLOADS' ) ;
302+ expectRSCPayloadPushScript ( allHtml ) ;
207303 } , 10000 ) ;
208304
209305 // Tests the boundary condition: payload just above ~32KB combined buffer capacity.
@@ -214,9 +310,9 @@ describe('streamServerRenderedReactComponent - RSC payload exceeding default hig
214310 // Push exactly 48KB — just above the ~32KB combined buffer capacity.
215311 const chunkSize = 1024 ;
216312 const chunkCount = 48 ;
217- const totalBytes = chunkSize * chunkCount ;
218- const chunk = Buffer . alloc ( chunkSize , 0x63 ) ; // fill with 'c'
219- for ( let i = 0 ; i < chunkCount ; i ++ ) {
313+ const chunk = toLengthPrefixedPayload ( 'c' . repeat ( chunkSize ) ) ;
314+ const totalBytes = chunk . length * chunkCount ;
315+ for ( let i = 0 ; i < chunkCount ; i += 1 ) {
220316 source . push ( chunk ) ;
221317 }
222318 source . push ( null ) ;
@@ -229,5 +325,6 @@ describe('streamServerRenderedReactComponent - RSC payload exceeding default hig
229325 expect ( allHtml ) . toContain ( 'rsc-content' ) ;
230326 expect ( allHtml ) . toContain ( `RSC payload: ${ totalBytes } bytes` ) ;
231327 expect ( allHtml ) . toContain ( 'REACT_ON_RAILS_RSC_PAYLOADS' ) ;
328+ expectRSCPayloadPushScript ( allHtml ) ;
232329 } , 5000 ) ;
233330} ) ;
0 commit comments