|  | 
| 12 | 12 |  * https://github.com/shakacode/react_on_rails/blob/master/REACT-ON-RAILS-PRO-LICENSE.md | 
| 13 | 13 |  */ | 
| 14 | 14 | 
 | 
| 15 |  | -import * as React from 'react'; | 
| 16 |  | -import { PassThrough, Readable } from 'stream'; | 
|  | 15 | +import { Readable } from 'stream'; | 
| 17 | 16 | 
 | 
| 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 | 17 | import handleError from 'react-on-rails/handleError'; | 
| 22 | 18 | import { renderToPipeableStream } from 'react-on-rails/ReactDOMServer'; | 
| 23 |  | -import { createResultObject, convertToError, validateComponent } from 'react-on-rails/serverRenderUtils'; | 
|  | 19 | +import { convertToError } from 'react-on-rails/serverRenderUtils'; | 
| 24 | 20 | import { | 
| 25 | 21 |   assertRailsContextWithServerStreamingCapabilities, | 
| 26 | 22 |   RenderParams, | 
| 27 | 23 |   StreamRenderState, | 
| 28 | 24 |   StreamableComponentResult, | 
| 29 |  | -  PipeableOrReadableStream, | 
| 30 |  | -  RailsContextWithServerStreamingCapabilities, | 
| 31 |  | -  assertRailsContextWithServerComponentMetadata, | 
| 32 | 25 | } from 'react-on-rails/types'; | 
| 33 |  | -import * as ComponentRegistry from './ComponentRegistry.ts'; | 
| 34 | 26 | import 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 |  | -}; | 
|  | 27 | +import {  | 
|  | 28 | +  StreamingTrackers, | 
|  | 29 | +  transformRenderStreamChunksToResultObject, | 
|  | 30 | +  streamServerRenderedComponent, | 
|  | 31 | + } from './streamingUtils.ts'; | 
| 159 | 32 | 
 | 
| 160 | 33 | const streamRenderReactComponent = ( | 
| 161 | 34 |   reactRenderingResult: StreamableComponentResult, | 
| @@ -228,102 +101,6 @@ const streamRenderReactComponent = ( | 
| 228 | 101 |   return readableStream; | 
| 229 | 102 | }; | 
| 230 | 103 | 
 | 
| 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 |  | - | 
| 327 | 104 | const streamServerRenderedReactComponent = (options: RenderParams): Readable => | 
| 328 | 105 |   streamServerRenderedComponent(options, streamRenderReactComponent); | 
| 329 | 106 | 
 | 
|  | 
0 commit comments