|
1 | | -import ReactDOMServer, { type PipeableStream } from 'react-dom/server'; |
2 | | -import { PassThrough, Readable } from 'stream'; |
| 1 | +import ReactDOMServer from 'react-dom/server'; |
3 | 2 | import type { ReactElement } from 'react'; |
4 | 3 |
|
5 | 4 | import ComponentRegistry from './ComponentRegistry'; |
6 | 5 | import createReactOutput from './createReactOutput'; |
7 | 6 | import { isPromise, isServerRenderHash } from './isServerRenderResult'; |
8 | 7 | import buildConsoleReplay from './buildConsoleReplay'; |
9 | 8 | import handleError from './handleError'; |
10 | | -import type { CreateReactOutputResult, RegisteredComponent, RenderParams, RenderResult, RenderingError, ServerRenderResult } from './types'; |
11 | | - |
12 | | -type RenderState = { |
13 | | - result: null | string | Promise<string>; |
14 | | - hasErrors: boolean; |
15 | | - error?: RenderingError; |
16 | | -}; |
17 | | - |
18 | | -type StreamRenderState = Omit<RenderState, 'result'> & { |
19 | | - result: null | Readable; |
20 | | - isShellReady: boolean; |
21 | | -}; |
22 | | - |
23 | | -type RenderOptions = { |
24 | | - componentName: string; |
25 | | - domNodeId?: string; |
26 | | - trace?: boolean; |
27 | | - renderingReturnsPromises: boolean; |
28 | | -}; |
29 | | - |
30 | | -function convertToError(e: unknown): Error { |
31 | | - return e instanceof Error ? e : new Error(String(e)); |
32 | | -} |
33 | | - |
34 | | -function validateComponent(componentObj: RegisteredComponent, componentName: string) { |
35 | | - if (componentObj.isRenderer) { |
36 | | - throw new Error(`Detected a renderer while server rendering component '${componentName}'. See https://github.com/shakacode/react_on_rails#renderer-functions`); |
37 | | - } |
38 | | -} |
| 9 | +import { createResultObject, convertToError, validateComponent } from './serverRenderUtils'; |
| 10 | +import type { CreateReactOutputResult, RenderParams, RenderResult, RenderState, RenderOptions, ServerRenderResult } from './types'; |
39 | 11 |
|
40 | 12 | function processServerRenderHash(result: ServerRenderResult, options: RenderOptions): RenderState { |
41 | 13 | const { redirectLocation, routeError } = result; |
@@ -104,16 +76,6 @@ function handleRenderingError(e: unknown, options: { componentName: string, thro |
104 | 76 | }; |
105 | 77 | } |
106 | 78 |
|
107 | | -function createResultObject(html: string | null, consoleReplayScript: string, renderState: RenderState | StreamRenderState): RenderResult { |
108 | | - return { |
109 | | - html, |
110 | | - consoleReplayScript, |
111 | | - hasErrors: renderState.hasErrors, |
112 | | - renderingError: renderState.error && { message: renderState.error.message, stack: renderState.error.stack }, |
113 | | - isShellReady: 'isShellReady' in renderState ? renderState.isShellReady : undefined, |
114 | | - }; |
115 | | -} |
116 | | - |
117 | 79 | async function createPromiseResult( |
118 | 80 | renderState: RenderState & { result: Promise<string> }, |
119 | 81 | componentName: string, |
@@ -203,131 +165,4 @@ const serverRenderReactComponent: typeof serverRenderReactComponentInternal = (o |
203 | 165 | } |
204 | 166 | }; |
205 | 167 |
|
206 | | -const stringToStream = (str: string): Readable => { |
207 | | - const stream = new PassThrough(); |
208 | | - stream.write(str); |
209 | | - stream.end(); |
210 | | - return stream; |
211 | | -}; |
212 | | - |
213 | | -const transformRenderStreamChunksToResultObject = (renderState: StreamRenderState) => { |
214 | | - const consoleHistory = console.history; |
215 | | - let previouslyReplayedConsoleMessages = 0; |
216 | | - |
217 | | - const transformStream = new PassThrough({ |
218 | | - transform(chunk, _, callback) { |
219 | | - const htmlChunk = chunk.toString(); |
220 | | - const consoleReplayScript = buildConsoleReplay(consoleHistory, previouslyReplayedConsoleMessages); |
221 | | - previouslyReplayedConsoleMessages = consoleHistory?.length || 0; |
222 | | - |
223 | | - const jsonChunk = JSON.stringify(createResultObject(htmlChunk, consoleReplayScript, renderState)); |
224 | | - |
225 | | - this.push(`${jsonChunk}\n`); |
226 | | - callback(); |
227 | | - } |
228 | | - }); |
229 | | - |
230 | | - let pipedStream: PipeableStream | null = null; |
231 | | - const pipeToTransform = (pipeableStream: PipeableStream) => { |
232 | | - pipeableStream.pipe(transformStream); |
233 | | - pipedStream = pipeableStream; |
234 | | - }; |
235 | | - // We need to wrap the transformStream in a Readable stream to properly handle errors: |
236 | | - // 1. If we returned transformStream directly, we couldn't emit errors into it externally |
237 | | - // 2. If an error is emitted into the transformStream, it would cause the render to fail |
238 | | - // 3. By wrapping in Readable.from(), we can explicitly emit errors into the readableStream without affecting the transformStream |
239 | | - // Note: Readable.from can merge multiple chunks into a single chunk, so we need to ensure that we can separate them later |
240 | | - const readableStream = Readable.from(transformStream); |
241 | | - |
242 | | - const writeChunk = (chunk: string) => transformStream.write(chunk); |
243 | | - const emitError = (error: unknown) => readableStream.emit('error', error); |
244 | | - const endStream = () => { |
245 | | - transformStream.end(); |
246 | | - pipedStream?.abort(); |
247 | | - } |
248 | | - return { readableStream, pipeToTransform, writeChunk, emitError, endStream }; |
249 | | -} |
250 | | - |
251 | | -const streamRenderReactComponent = (reactRenderingResult: ReactElement, options: RenderParams) => { |
252 | | - const { name: componentName, throwJsErrors } = options; |
253 | | - const renderState: StreamRenderState = { |
254 | | - result: null, |
255 | | - hasErrors: false, |
256 | | - isShellReady: false |
257 | | - }; |
258 | | - |
259 | | - const { |
260 | | - readableStream, |
261 | | - pipeToTransform, |
262 | | - writeChunk, |
263 | | - emitError, |
264 | | - endStream |
265 | | - } = transformRenderStreamChunksToResultObject(renderState); |
266 | | - |
267 | | - const renderingStream = ReactDOMServer.renderToPipeableStream(reactRenderingResult, { |
268 | | - onShellError(e) { |
269 | | - const error = convertToError(e); |
270 | | - renderState.hasErrors = true; |
271 | | - renderState.error = error; |
272 | | - |
273 | | - if (throwJsErrors) { |
274 | | - emitError(error); |
275 | | - } |
276 | | - |
277 | | - const errorHtml = handleError({ e: error, name: componentName, serverSide: true }); |
278 | | - writeChunk(errorHtml); |
279 | | - endStream(); |
280 | | - }, |
281 | | - onShellReady() { |
282 | | - renderState.isShellReady = true; |
283 | | - pipeToTransform(renderingStream); |
284 | | - }, |
285 | | - onError(e) { |
286 | | - if (!renderState.isShellReady) { |
287 | | - return; |
288 | | - } |
289 | | - const error = convertToError(e); |
290 | | - if (throwJsErrors) { |
291 | | - emitError(error); |
292 | | - } |
293 | | - renderState.hasErrors = true; |
294 | | - renderState.error = error; |
295 | | - }, |
296 | | - }); |
297 | | - |
298 | | - return readableStream; |
299 | | -} |
300 | | - |
301 | | -export const streamServerRenderedReactComponent = (options: RenderParams): Readable => { |
302 | | - const { name: componentName, domNodeId, trace, props, railsContext, throwJsErrors } = options; |
303 | | - |
304 | | - try { |
305 | | - const componentObj = ComponentRegistry.get(componentName); |
306 | | - validateComponent(componentObj, componentName); |
307 | | - |
308 | | - const reactRenderingResult = createReactOutput({ |
309 | | - componentObj, |
310 | | - domNodeId, |
311 | | - trace, |
312 | | - props, |
313 | | - railsContext, |
314 | | - }); |
315 | | - |
316 | | - if (isServerRenderHash(reactRenderingResult) || isPromise(reactRenderingResult)) { |
317 | | - throw new Error('Server rendering of streams is not supported for server render hashes or promises.'); |
318 | | - } |
319 | | - |
320 | | - return streamRenderReactComponent(reactRenderingResult, options); |
321 | | - } catch (e) { |
322 | | - if (throwJsErrors) { |
323 | | - throw e; |
324 | | - } |
325 | | - |
326 | | - const error = convertToError(e); |
327 | | - const htmlResult = handleError({ e: error, name: componentName, serverSide: true }); |
328 | | - const jsonResult = JSON.stringify(createResultObject(htmlResult, buildConsoleReplay(), { hasErrors: true, error, result: null })); |
329 | | - return stringToStream(jsonResult); |
330 | | - } |
331 | | -}; |
332 | | - |
333 | 168 | export default serverRenderReactComponent; |
0 commit comments