1212 * https://github.com/shakacode/react_on_rails/blob/master/REACT-ON-RAILS-PRO-LICENSE.md
1313 */
1414
15- import * as React from 'react' ;
16- import { PassThrough , Readable } from 'stream' ;
15+ import { Readable } from 'stream' ;
1716
18- import createReactOutput from 'react-on-rails/createReactOutput' ;
19- import { isPromise , isServerRenderHash } from 'react-on-rails/isServerRenderResult' ;
20- import buildConsoleReplay from 'react-on-rails/buildConsoleReplay' ;
21- import handleError from 'react-on-rails/handleError' ;
2217import { renderToPipeableStream } from 'react-on-rails/ReactDOMServer' ;
23- import { createResultObject , convertToError , validateComponent } from 'react-on-rails/serverRenderUtils' ;
18+ import { convertToError } from 'react-on-rails/serverRenderUtils' ;
2419import {
2520 assertRailsContextWithServerStreamingCapabilities ,
2621 RenderParams ,
2722 StreamRenderState ,
2823 StreamableComponentResult ,
29- PipeableOrReadableStream ,
30- RailsContextWithServerStreamingCapabilities ,
31- assertRailsContextWithServerComponentMetadata ,
3224} from 'react-on-rails/types' ;
33- import * as ComponentRegistry from './ComponentRegistry.ts' ;
3425import injectRSCPayload from './injectRSCPayload.ts' ;
35- import PostSSRHookTracker from './PostSSRHookTracker.ts' ;
36- import RSCRequestTracker from './RSCRequestTracker.ts' ;
37-
38- type BufferedEvent = {
39- event : 'data' | 'error' | 'end' ;
40- data : unknown ;
41- } ;
42-
43- /**
44- * Creates a new Readable stream that safely buffers all events from the input stream until reading begins.
45- *
46- * This function solves two important problems:
47- * 1. Error handling: If an error occurs on the source stream before error listeners are attached,
48- * it would normally crash the process. This wrapper buffers error events until reading begins,
49- * ensuring errors are properly handled once listeners are ready.
50- * 2. Event ordering: All events (data, error, end) are buffered and replayed in the exact order
51- * they were received, maintaining the correct sequence even if events occur before reading starts.
52- *
53- * @param stream - The source Readable stream to buffer
54- * @returns {Object } An object containing:
55- * - stream: A new Readable stream that will buffer and replay all events
56- * - emitError: A function to manually emit errors into the stream
57- */
58- const bufferStream = ( stream : Readable ) => {
59- const bufferedEvents : BufferedEvent [ ] = [ ] ;
60- let startedReading = false ;
61-
62- const listeners = ( [ 'data' , 'error' , 'end' ] as const ) . map ( ( event ) => {
63- const listener = ( data : unknown ) => {
64- if ( ! startedReading ) {
65- bufferedEvents . push ( { event, data } ) ;
66- }
67- } ;
68- stream . on ( event , listener ) ;
69- return { event, listener } ;
70- } ) ;
71-
72- const bufferedStream = new Readable ( {
73- read ( ) {
74- if ( startedReading ) return ;
75- startedReading = true ;
76-
77- // Remove initial listeners
78- listeners . forEach ( ( { event, listener } ) => stream . off ( event , listener ) ) ;
79- const handleEvent = ( { event, data } : BufferedEvent ) => {
80- if ( event === 'data' ) {
81- this . push ( data ) ;
82- } else if ( event === 'error' ) {
83- this . emit ( 'error' , data ) ;
84- } else {
85- this . push ( null ) ;
86- }
87- } ;
88-
89- // Replay buffered events
90- bufferedEvents . forEach ( handleEvent ) ;
91-
92- // Attach new listeners for future events
93- ( [ 'data' , 'error' , 'end' ] as const ) . forEach ( ( event ) => {
94- stream . on ( event , ( data : unknown ) => handleEvent ( { event, data } ) ) ;
95- } ) ;
96- } ,
97- } ) ;
98-
99- return {
100- stream : bufferedStream ,
101- emitError : ( error : unknown ) => {
102- if ( startedReading ) {
103- bufferedStream . emit ( 'error' , error ) ;
104- } else {
105- bufferedEvents . push ( { event : 'error' , data : error } ) ;
106- }
107- } ,
108- } ;
109- } ;
110-
111- export const transformRenderStreamChunksToResultObject = ( renderState : StreamRenderState ) => {
112- const consoleHistory = console . history ;
113- let previouslyReplayedConsoleMessages = 0 ;
114-
115- const transformStream = new PassThrough ( {
116- transform ( chunk : Buffer , _ , callback ) {
117- const htmlChunk = chunk . toString ( ) ;
118- const consoleReplayScript = buildConsoleReplay ( consoleHistory , previouslyReplayedConsoleMessages ) ;
119- previouslyReplayedConsoleMessages = consoleHistory ?. length || 0 ;
120- const jsonChunk = JSON . stringify ( createResultObject ( htmlChunk , consoleReplayScript , renderState ) ) ;
121- this . push ( `${ jsonChunk } \n` ) ;
122-
123- // Reset the render state to ensure that the error is not carried over to the next chunk
124- // eslint-disable-next-line no-param-reassign
125- renderState . error = undefined ;
126- // eslint-disable-next-line no-param-reassign
127- renderState . hasErrors = false ;
128-
129- callback ( ) ;
130- } ,
131- } ) ;
132-
133- let pipedStream : PipeableOrReadableStream | null = null ;
134- const pipeToTransform = ( pipeableStream : PipeableOrReadableStream ) => {
135- pipeableStream . pipe ( transformStream ) ;
136- pipedStream = pipeableStream ;
137- } ;
138- // We need to wrap the transformStream in a Readable stream to properly handle errors:
139- // 1. If we returned transformStream directly, we couldn't emit errors into it externally
140- // 2. If an error is emitted into the transformStream, it would cause the render to fail
141- // 3. By wrapping in Readable.from(), we can explicitly emit errors into the readableStream without affecting the transformStream
142- // Note: Readable.from can merge multiple chunks into a single chunk, so we need to ensure that we can separate them later
143- const { stream : readableStream , emitError } = bufferStream ( transformStream ) ;
144-
145- const writeChunk = ( chunk : string ) => transformStream . write ( chunk ) ;
146- const endStream = ( ) => {
147- transformStream . end ( ) ;
148- if ( pipedStream && 'abort' in pipedStream ) {
149- pipedStream . abort ( ) ;
150- }
151- } ;
152- return { readableStream, pipeToTransform, writeChunk, emitError, endStream } ;
153- } ;
154-
155- export type StreamingTrackers = {
156- postSSRHookTracker : PostSSRHookTracker ;
157- rscRequestTracker : RSCRequestTracker ;
158- } ;
26+ import {
27+ streamServerRenderedComponent ,
28+ StreamingTrackers ,
29+ transformRenderStreamChunksToResultObject ,
30+ } from './streamingUtils.ts' ;
31+ import handleError from './handleError.ts' ;
15932
16033const streamRenderReactComponent = (
16134 reactRenderingResult : StreamableComponentResult ,
@@ -182,9 +55,8 @@ const streamRenderReactComponent = (
18255 } ;
18356
18457 const sendErrorHtml = ( error : Error ) => {
185- const errorHtml = handleError ( { e : error , name : componentName , serverSide : true } ) ;
186- writeChunk ( errorHtml ) ;
187- endStream ( ) ;
58+ const errorHtmlStream = handleError ( { e : error , name : componentName , serverSide : true } ) ;
59+ pipeToTransform ( errorHtmlStream ) ;
18860 } ;
18961
19062 assertRailsContextWithServerStreamingCapabilities ( railsContext ) ;
@@ -228,103 +100,7 @@ const streamRenderReactComponent = (
228100 return readableStream ;
229101} ;
230102
231- type StreamRenderer < T , P extends RenderParams > = (
232- reactElement : StreamableComponentResult ,
233- options : P ,
234- streamingTrackers : StreamingTrackers ,
235- ) => T ;
236-
237- /**
238- * This module implements request-scoped tracking for React Server Components (RSC)
239- * and post-SSR hooks using local tracker instances per request.
240- *
241- * DESIGN PRINCIPLES:
242- * - Each request gets its own PostSSRHookTracker and RSCRequestTracker instances
243- * - State is automatically garbage collected when request completes
244- * - No shared state between concurrent requests
245- * - Simple, predictable cleanup lifecycle
246- *
247- * TRACKER RESPONSIBILITIES:
248- * - PostSSRHookTracker: Manages hooks that run after SSR completes
249- * - RSCRequestTracker: Handles RSC payload generation and stream tracking
250- * - Both inject their capabilities into the Rails context for component access
251- */
252-
253- export const streamServerRenderedComponent = < T , P extends RenderParams > (
254- options : P ,
255- renderStrategy : StreamRenderer < T , P > ,
256- ) : T => {
257- const { name : componentName , domNodeId, trace, props, railsContext, throwJsErrors } = options ;
258-
259- assertRailsContextWithServerComponentMetadata ( railsContext ) ;
260- const postSSRHookTracker = new PostSSRHookTracker ( ) ;
261- const rscRequestTracker = new RSCRequestTracker ( railsContext ) ;
262- const streamingTrackers = {
263- postSSRHookTracker,
264- rscRequestTracker,
265- } ;
266-
267- const railsContextWithStreamingCapabilities : RailsContextWithServerStreamingCapabilities = {
268- ...railsContext ,
269- addPostSSRHook : postSSRHookTracker . addPostSSRHook . bind ( postSSRHookTracker ) ,
270- getRSCPayloadStream : rscRequestTracker . getRSCPayloadStream . bind ( rscRequestTracker ) ,
271- } ;
272-
273- const optionsWithStreamingCapabilities = {
274- ...options ,
275- railsContext : railsContextWithStreamingCapabilities ,
276- } ;
277-
278- try {
279- const componentObj = ComponentRegistry . get ( componentName ) ;
280- validateComponent ( componentObj , componentName ) ;
281-
282- const reactRenderingResult = createReactOutput ( {
283- componentObj,
284- domNodeId,
285- trace,
286- props,
287- railsContext : railsContextWithStreamingCapabilities ,
288- } ) ;
289-
290- if ( isServerRenderHash ( reactRenderingResult ) ) {
291- throw new Error ( 'Server rendering of streams is not supported for server render hashes.' ) ;
292- }
293-
294- if ( isPromise ( reactRenderingResult ) ) {
295- const promiseAfterRejectingHash = reactRenderingResult . then ( ( result ) => {
296- if ( ! React . isValidElement ( result ) ) {
297- throw new Error (
298- `Invalid React element detected while rendering ${ componentName } . If you are trying to stream a component registered as a render function, ` +
299- `please ensure that the render function returns a valid React component, not a server render hash. ` +
300- `This error typically occurs when the render function does not return a React element or returns an incorrect type.` ,
301- ) ;
302- }
303- return result ;
304- } ) ;
305- return renderStrategy ( promiseAfterRejectingHash , optionsWithStreamingCapabilities , streamingTrackers ) ;
306- }
307-
308- return renderStrategy ( reactRenderingResult , optionsWithStreamingCapabilities , streamingTrackers ) ;
309- } catch ( e ) {
310- const { readableStream, writeChunk, emitError, endStream } = transformRenderStreamChunksToResultObject ( {
311- hasErrors : true ,
312- isShellReady : false ,
313- result : null ,
314- } ) ;
315- if ( throwJsErrors ) {
316- emitError ( e ) ;
317- }
318-
319- const error = convertToError ( e ) ;
320- const htmlResult = handleError ( { e : error , name : componentName , serverSide : true } ) ;
321- writeChunk ( htmlResult ) ;
322- endStream ( ) ;
323- return readableStream as T ;
324- }
325- } ;
326-
327103const streamServerRenderedReactComponent = ( options : RenderParams ) : Readable =>
328- streamServerRenderedComponent ( options , streamRenderReactComponent ) ;
104+ streamServerRenderedComponent ( options , streamRenderReactComponent , handleError ) ;
329105
330106export default streamServerRenderedReactComponent ;
0 commit comments