main
js 434 lines 13 KB
Raw
1 /**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 *
7 * @flow
8 */
9
10 import type {
11 Request,
12 PostponedState,
13 ErrorInfo,
14 } from 'react-server/src/ReactFizzServer';
15 import type {ReactNodeList, ReactFormState} from 'shared/ReactTypes';
16 import type {Writable} from 'stream';
17 import type {
18 BootstrapScriptDescriptor,
19 HeadersDescriptor,
20 } from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
21 import type {Destination} from 'react-server/src/ReactServerStreamConfigNode';
22 import type {ImportMap} from '../shared/ReactDOMTypes';
23
24 import ReactVersion from 'shared/ReactVersion';
25
26 import {
27 createRequest,
28 resumeRequest,
29 startWork,
30 startFlowing,
31 stopFlowing,
32 abort,
33 attachAbortSignal,
34 prepareForStartFlowingIfBeforeAllReady,
35 } from 'react-server/src/ReactFizzServer';
36
37 import {
38 createResumableState,
39 createRenderState,
40 resumeRenderState,
41 createRootFormatContext,
42 } from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
43
44 import {textEncoder} from 'react-server/src/ReactServerStreamConfigNode';
45
46 import {ensureCorrectIsomorphicReactVersion} from '../shared/ensureCorrectIsomorphicReactVersion';
47 ensureCorrectIsomorphicReactVersion();
48
49 function createDrainHandler(destination: Destination, request: Request) {
50 return () => startFlowing(request, destination);
51 }
52
53 function createCancelHandler(request: Request, reason: string) {
54 return () => {
55 stopFlowing(request);
56 // eslint-disable-next-line react-internal/prod-error-codes
57 abort(request, new Error(reason));
58 };
59 }
60
61 type NonceOption =
62 | string
63 | {
64 script?: string,
65 style?: string,
66 };
67
68 type Options = {
69 identifierPrefix?: string,
70 namespaceURI?: string,
71 nonce?: NonceOption,
72 bootstrapScriptContent?: string,
73 bootstrapScripts?: Array<string | BootstrapScriptDescriptor>,
74 bootstrapModules?: Array<string | BootstrapScriptDescriptor>,
75 progressiveChunkSize?: number,
76 onShellReady?: () => void,
77 onShellError?: (error: mixed) => void,
78 onAllReady?: () => void,
79 onError?: (error: mixed, errorInfo: ErrorInfo) => ?string,
80 onBrowserBailout?: (error: mixed, errorInfo: ErrorInfo) => void,
81 unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
82 importMap?: ImportMap,
83 formState?: ReactFormState<any, any> | null,
84 onHeaders?: (headers: HeadersDescriptor) => void,
85 maxHeadersLength?: number,
86 };
87
88 type ResumeOptions = {
89 nonce?: NonceOption,
90 onShellReady?: () => void,
91 onShellError?: (error: mixed) => void,
92 onAllReady?: () => void,
93 onError?: (error: mixed, errorInfo: ErrorInfo) => ?string,
94 onBrowserBailout?: (error: mixed, errorInfo: ErrorInfo) => void,
95 };
96
97 type PipeableStream = {
98 // Cancel any pending I/O and put anything remaining into
99 // client rendered mode.
100 abort(reason: mixed): void,
101 pipe<T: Writable>(destination: T): T,
102 };
103
104 function createRequestImpl(children: ReactNodeList, options: void | Options) {
105 const resumableState = createResumableState(
106 options ? options.identifierPrefix : undefined,
107 options ? options.unstable_externalRuntimeSrc : undefined,
108 options ? options.bootstrapScriptContent : undefined,
109 options ? options.bootstrapScripts : undefined,
110 options ? options.bootstrapModules : undefined,
111 );
112 return createRequest(
113 children,
114 resumableState,
115 createRenderState(
116 resumableState,
117 options ? options.nonce : undefined,
118 options ? options.unstable_externalRuntimeSrc : undefined,
119 options ? options.importMap : undefined,
120 options ? options.onHeaders : undefined,
121 options ? options.maxHeadersLength : undefined,
122 ),
123 createRootFormatContext(options ? options.namespaceURI : undefined),
124 options ? options.progressiveChunkSize : undefined,
125 options ? options.onError : undefined,
126 options ? options.onBrowserBailout : undefined,
127 options ? options.onAllReady : undefined,
128 options ? options.onShellReady : undefined,
129 options ? options.onShellError : undefined,
130 undefined,
131 options ? options.formState : undefined,
132 );
133 }
134
135 function renderToPipeableStream(
136 children: ReactNodeList,
137 options?: Options,
138 ): PipeableStream {
139 const request = createRequestImpl(children, options);
140 let hasStartedFlowing = false;
141 startWork(request);
142 return {
143 pipe<T: Writable>(destination: T): T {
144 if (hasStartedFlowing) {
145 throw new Error(
146 'React currently only supports piping to one writable stream.',
147 );
148 }
149 hasStartedFlowing = true;
150 prepareForStartFlowingIfBeforeAllReady(request);
151 startFlowing(request, destination);
152 destination.on('drain', createDrainHandler(destination, request));
153 destination.on(
154 'error',
155 createCancelHandler(
156 request,
157 'The destination stream errored while writing data.',
158 ),
159 );
160 destination.on(
161 'close',
162 createCancelHandler(request, 'The destination stream closed early.'),
163 );
164 return destination;
165 },
166 abort(reason: mixed) {
167 abort(request, reason);
168 },
169 };
170 }
171
172 function createFakeWritableFromReadableStreamController(
173 controller: ReadableStreamController,
174 ): Writable {
175 // The current host config expects a Writable so we create
176 // a fake writable for now to push into the Readable.
177 return {
178 write(chunk: string | Uint8Array) {
179 if (typeof chunk === 'string') {
180 chunk = textEncoder.encode(chunk);
181 }
182 controller.enqueue(chunk);
183 // in web streams there is no backpressure so we can alwas write more
184 return true;
185 },
186 end() {
187 controller.close();
188 },
189 destroy(error) {
190 // $FlowFixMe[method-unbinding]
191 if (typeof controller.error === 'function') {
192 // $FlowFixMe[incompatible-call]: This is an Error object or the destination accepts other types.
193 controller.error(error);
194 } else {
195 controller.close();
196 }
197 },
198 } as any;
199 }
200
201 // TODO: Move to sub-classing ReadableStream.
202 type ReactDOMServerReadableStream = ReadableStream & {
203 allReady: Promise<void>,
204 };
205
206 type WebStreamsOptions = Omit<
207 Options,
208 'onShellReady' | 'onShellError' | 'onAllReady' | 'onHeaders',
209 > & {signal: AbortSignal, onHeaders?: (headers: Headers) => void};
210
211 function renderToReadableStream(
212 children: ReactNodeList,
213 options?: WebStreamsOptions,
214 ): Promise<ReactDOMServerReadableStream> {
215 return new Promise((resolve, reject) => {
216 let onFatalError;
217 let onAllReady;
218 const allReady = new Promise<void>((res, rej) => {
219 onAllReady = res;
220 onFatalError = rej;
221 });
222
223 function onShellReady() {
224 let writable: Writable;
225 const stream: ReactDOMServerReadableStream = new ReadableStream(
226 {
227 type: 'bytes',
228 start: (controller): ?Promise<void> => {
229 writable =
230 createFakeWritableFromReadableStreamController(controller);
231 },
232 pull: (controller): ?Promise<void> => {
233 startFlowing(request, writable);
234 },
235 cancel: (reason): ?Promise<void> => {
236 stopFlowing(request);
237 abort(request, reason);
238 },
239 },
240 // $FlowFixMe[prop-missing] size() methods are not allowed on byte streams.
241 // $FlowFixMe[incompatible-type]
242 {highWaterMark: 0},
243 ) as any;
244 // TODO: Move to sub-classing ReadableStream.
245 stream.allReady = allReady;
246 resolve(stream);
247 }
248 function onShellError(error: mixed) {
249 // If the shell errors the caller of `renderToReadableStream` won't have access to `allReady`.
250 // However, `allReady` will be rejected by `onFatalError` as well.
251 // So we need to catch the duplicate, uncatchable fatal error in `allReady` to prevent a `UnhandledPromiseRejection`.
252 allReady.catch(() => {});
253 reject(error);
254 }
255
256 const onHeaders = options ? options.onHeaders : undefined;
257 let onHeadersImpl;
258 if (onHeaders) {
259 onHeadersImpl = (headersDescriptor: HeadersDescriptor) => {
260 onHeaders(new Headers(headersDescriptor));
261 };
262 }
263
264 const resumableState = createResumableState(
265 options ? options.identifierPrefix : undefined,
266 options ? options.unstable_externalRuntimeSrc : undefined,
267 options ? options.bootstrapScriptContent : undefined,
268 options ? options.bootstrapScripts : undefined,
269 options ? options.bootstrapModules : undefined,
270 );
271 const request = createRequest(
272 children,
273 resumableState,
274 createRenderState(
275 resumableState,
276 options ? options.nonce : undefined,
277 options ? options.unstable_externalRuntimeSrc : undefined,
278 options ? options.importMap : undefined,
279 onHeadersImpl,
280 options ? options.maxHeadersLength : undefined,
281 ),
282 createRootFormatContext(options ? options.namespaceURI : undefined),
283 options ? options.progressiveChunkSize : undefined,
284 options ? options.onError : undefined,
285 options ? options.onBrowserBailout : undefined,
286 onAllReady,
287 onShellReady,
288 onShellError,
289 onFatalError,
290 options ? options.formState : undefined,
291 );
292 if (options && options.signal) {
293 attachAbortSignal(request, options.signal);
294 }
295 startWork(request);
296 });
297 }
298
299 function resumeRequestImpl(
300 children: ReactNodeList,
301 postponedState: PostponedState,
302 options: void | ResumeOptions,
303 ) {
304 return resumeRequest(
305 children,
306 postponedState,
307 resumeRenderState(
308 postponedState.resumableState,
309 options ? options.nonce : undefined,
310 ),
311 options ? options.onError : undefined,
312 options ? options.onBrowserBailout : undefined,
313 options ? options.onAllReady : undefined,
314 options ? options.onShellReady : undefined,
315 options ? options.onShellError : undefined,
316 undefined,
317 );
318 }
319
320 function resumeToPipeableStream(
321 children: ReactNodeList,
322 postponedState: PostponedState,
323 options?: ResumeOptions,
324 ): PipeableStream {
325 const request = resumeRequestImpl(children, postponedState, options);
326 let hasStartedFlowing = false;
327 startWork(request);
328 return {
329 pipe<T: Writable>(destination: T): T {
330 if (hasStartedFlowing) {
331 throw new Error(
332 'React currently only supports piping to one writable stream.',
333 );
334 }
335 hasStartedFlowing = true;
336 startFlowing(request, destination);
337 destination.on('drain', createDrainHandler(destination, request));
338 destination.on(
339 'error',
340 createCancelHandler(
341 request,
342 'The destination stream errored while writing data.',
343 ),
344 );
345 destination.on(
346 'close',
347 createCancelHandler(request, 'The destination stream closed early.'),
348 );
349 return destination;
350 },
351 abort(reason: mixed) {
352 abort(request, reason);
353 },
354 };
355 }
356
357 type WebStreamsResumeOptions = Omit<
358 Options,
359 'onShellReady' | 'onShellError' | 'onAllReady',
360 > & {signal: AbortSignal};
361
362 function resume(
363 children: ReactNodeList,
364 postponedState: PostponedState,
365 options?: WebStreamsResumeOptions,
366 ): Promise<ReactDOMServerReadableStream> {
367 return new Promise((resolve, reject) => {
368 let onFatalError;
369 let onAllReady;
370 const allReady = new Promise<void>((res, rej) => {
371 onAllReady = res;
372 onFatalError = rej;
373 });
374
375 function onShellReady() {
376 let writable: Writable;
377 const stream: ReactDOMServerReadableStream = new ReadableStream(
378 {
379 type: 'bytes',
380 start: (controller): ?Promise<void> => {
381 writable =
382 createFakeWritableFromReadableStreamController(controller);
383 },
384 pull: (controller): ?Promise<void> => {
385 startFlowing(request, writable);
386 },
387 cancel: (reason): ?Promise<void> => {
388 stopFlowing(request);
389 abort(request, reason);
390 },
391 },
392 // $FlowFixMe[prop-missing] size() methods are not allowed on byte streams.
393 // $FlowFixMe[incompatible-type]
394 {highWaterMark: 0},
395 ) as any;
396 // TODO: Move to sub-classing ReadableStream.
397 stream.allReady = allReady;
398 resolve(stream);
399 }
400 function onShellError(error: mixed) {
401 // If the shell errors the caller of `renderToReadableStream` won't have access to `allReady`.
402 // However, `allReady` will be rejected by `onFatalError` as well.
403 // So we need to catch the duplicate, uncatchable fatal error in `allReady` to prevent a `UnhandledPromiseRejection`.
404 allReady.catch(() => {});
405 reject(error);
406 }
407 const request = resumeRequest(
408 children,
409 postponedState,
410 resumeRenderState(
411 postponedState.resumableState,
412 options ? options.nonce : undefined,
413 ),
414 options ? options.onError : undefined,
415 options ? options.onBrowserBailout : undefined,
416 onAllReady,
417 onShellReady,
418 onShellError,
419 onFatalError,
420 );
421 if (options && options.signal) {
422 attachAbortSignal(request, options.signal);
423 }
424 startWork(request);
425 });
426 }
427
428 export {
429 renderToPipeableStream,
430 renderToReadableStream,
431 resumeToPipeableStream,
432 resume,
433 ReactVersion as version,
434 };