|
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