main
js 760 lines 21.2 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 ReactClientValue,
13 } from 'react-server/src/ReactFlightServer';
14 import type {Destination} from 'react-server/src/ReactServerStreamConfigNode';
15 import type {ClientManifest} from './ReactFlightServerConfigWebpackBundler';
16 import type {ServerManifest} from 'react-client/src/ReactFlightClientConfig';
17 import type {Busboy} from 'busboy';
18 import type {Writable} from 'stream';
19 import type {Thenable} from 'shared/ReactTypes';
20
21 import type {Duplex} from 'stream';
22
23 import {Readable} from 'stream';
24
25 import noop from 'shared/noop';
26 import {ASYNC_ITERATOR} from 'shared/ReactSymbols';
27
28 import {
29 createRequest,
30 createPrerenderRequest,
31 startWork,
32 startFlowing,
33 startFlowingDebug,
34 stopFlowing,
35 abort,
36 attachAbortSignal,
37 resolveDebugMessage,
38 closeDebugChannel,
39 } from 'react-server/src/ReactFlightServer';
40
41 import {
42 createResponse,
43 reportGlobalError,
44 close,
45 resolveField,
46 resolveFile,
47 resolveFileInfo,
48 resolveFileChunk,
49 resolveFileComplete,
50 getRoot,
51 } from 'react-server/src/ReactFlightReplyServer';
52
53 import {
54 decodeAction,
55 decodeFormState,
56 } from 'react-server/src/ReactFlightActionServer';
57
58 export {
59 registerServerReference,
60 registerClientReference,
61 createClientModuleProxy,
62 } from '../ReactFlightWebpackReferences';
63
64 import {
65 createStringDecoder,
66 readPartialStringChunk,
67 readFinalStringChunk,
68 } from 'react-client/src/ReactFlightClientStreamConfigNode';
69
70 import {textEncoder} from 'react-server/src/ReactServerStreamConfigNode';
71
72 import type {TemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences';
73 import type {FileHandle} from 'react-server/src/ReactFlightReplyServer';
74
75 export {createTemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences';
76
77 export type {TemporaryReferenceSet};
78
79 function createDrainHandler(destination: Destination, request: Request) {
80 return () => startFlowing(request, destination);
81 }
82
83 function createCancelHandler(request: Request, reason: string) {
84 return () => {
85 stopFlowing(request);
86 abort(request, new Error(reason));
87 };
88 }
89
90 function startReadingFromDebugChannelReadable(
91 request: Request,
92 stream: Readable | WebSocket,
93 ): void {
94 const stringDecoder = createStringDecoder();
95 let lastWasPartial = false;
96 let stringBuffer = '';
97 function onData(chunk: string | Uint8Array) {
98 if (typeof chunk === 'string') {
99 if (lastWasPartial) {
100 stringBuffer += readFinalStringChunk(stringDecoder, new Uint8Array(0));
101 lastWasPartial = false;
102 }
103 stringBuffer += chunk;
104 } else {
105 const buffer: Uint8Array = chunk as any;
106 stringBuffer += readPartialStringChunk(stringDecoder, buffer);
107 lastWasPartial = true;
108 }
109 const messages = stringBuffer.split('\n');
110 for (let i = 0; i < messages.length - 1; i++) {
111 resolveDebugMessage(request, messages[i]);
112 }
113 stringBuffer = messages[messages.length - 1];
114 }
115 function onError(error: mixed) {
116 abort(
117 request,
118 new Error('Lost connection to the Debug Channel.', {
119 cause: error,
120 }),
121 );
122 }
123 function onClose() {
124 closeDebugChannel(request);
125 }
126 if (
127 // $FlowFixMe[method-unbinding]
128 typeof stream.addEventListener === 'function' &&
129 // $FlowFixMe[method-unbinding]
130 typeof stream.binaryType === 'string'
131 ) {
132 const ws: WebSocket = stream as any;
133 ws.binaryType = 'arraybuffer';
134 ws.addEventListener('message', event => {
135 // $FlowFixMe[incompatible-type]
136 onData(event.data);
137 });
138 ws.addEventListener('error', event => {
139 // $FlowFixMe[prop-missing]
140 onError(event.error);
141 });
142 ws.addEventListener('close', onClose);
143 } else {
144 const readable: Readable = stream as any;
145 readable.on('data', onData);
146 readable.on('error', onError);
147 readable.on('end', onClose);
148 }
149 }
150
151 type Options = {
152 debugChannel?: Readable | Writable | Duplex | WebSocket,
153 environmentName?: string | (() => string),
154 filterStackFrame?: (url: string, functionName: string) => boolean,
155 onError?: (error: mixed) => void,
156 identifierPrefix?: string,
157 temporaryReferences?: TemporaryReferenceSet,
158 startTime?: number,
159 };
160
161 type PipeableStream = {
162 abort(reason: mixed): void,
163 pipe<T: Writable>(destination: T): T,
164 };
165
166 function renderToPipeableStream(
167 model: ReactClientValue,
168 webpackMap: ClientManifest,
169 options?: Options,
170 ): PipeableStream {
171 const debugChannel = __DEV__ && options ? options.debugChannel : undefined;
172 const debugChannelReadable: void | Readable | WebSocket =
173 __DEV__ &&
174 debugChannel !== undefined &&
175 // $FlowFixMe[method-unbinding]
176 (typeof debugChannel.read === 'function' ||
177 typeof debugChannel.readyState === 'number')
178 ? (debugChannel as any)
179 : undefined;
180 const debugChannelWritable: void | Writable =
181 __DEV__ && debugChannel !== undefined
182 ? // $FlowFixMe[method-unbinding]
183 typeof debugChannel.write === 'function'
184 ? (debugChannel as any)
185 : // $FlowFixMe[method-unbinding]
186 typeof debugChannel.send === 'function'
187 ? createFakeWritableFromWebSocket(debugChannel as any)
188 : undefined
189 : undefined;
190 const request = createRequest(
191 model,
192 webpackMap,
193 options ? options.onError : undefined,
194 options ? options.identifierPrefix : undefined,
195 options ? options.temporaryReferences : undefined,
196 options ? options.startTime : undefined,
197 __DEV__ && options ? options.environmentName : undefined,
198 __DEV__ && options ? options.filterStackFrame : undefined,
199 debugChannelReadable !== undefined,
200 );
201 let hasStartedFlowing = false;
202 startWork(request);
203 if (debugChannelWritable !== undefined) {
204 startFlowingDebug(request, debugChannelWritable);
205 }
206 if (debugChannelReadable !== undefined) {
207 startReadingFromDebugChannelReadable(request, debugChannelReadable);
208 }
209 return {
210 pipe<T: Writable>(destination: T): T {
211 if (hasStartedFlowing) {
212 throw new Error(
213 'React currently only supports piping to one writable stream.',
214 );
215 }
216 hasStartedFlowing = true;
217 startFlowing(request, destination);
218 destination.on('drain', createDrainHandler(destination, request));
219 destination.on(
220 'error',
221 createCancelHandler(
222 request,
223 'The destination stream errored while writing data.',
224 ),
225 );
226 // We don't close until the debug channel closes.
227 if (!__DEV__ || debugChannelReadable === undefined) {
228 destination.on(
229 'close',
230 createCancelHandler(request, 'The destination stream closed early.'),
231 );
232 }
233 return destination;
234 },
235 abort(reason: mixed) {
236 abort(request, reason);
237 },
238 };
239 }
240
241 function createFakeWritableFromWebSocket(webSocket: WebSocket): Writable {
242 return {
243 write(chunk: string | Uint8Array) {
244 webSocket.send(chunk as any);
245 return true;
246 },
247 end() {
248 webSocket.close();
249 },
250 destroy(reason) {
251 if (typeof reason === 'object' && reason !== null) {
252 reason = reason.message;
253 }
254 if (typeof reason === 'string') {
255 webSocket.close(1011, reason);
256 } else {
257 webSocket.close(1011);
258 }
259 },
260 } as any;
261 }
262
263 function createFakeWritableFromReadableStreamController(
264 controller: ReadableStreamController,
265 ): Writable {
266 // The current host config expects a Writable so we create
267 // a fake writable for now to push into the Readable.
268 return {
269 write(chunk: string | Uint8Array) {
270 if (typeof chunk === 'string') {
271 chunk = textEncoder.encode(chunk);
272 }
273 controller.enqueue(chunk);
274 // in web streams there is no backpressure so we can always write more
275 return true;
276 },
277 end() {
278 controller.close();
279 },
280 destroy(error) {
281 // $FlowFixMe[method-unbinding]
282 if (typeof controller.error === 'function') {
283 // $FlowFixMe[incompatible-call]: This is an Error object or the destination accepts other types.
284 controller.error(error);
285 } else {
286 controller.close();
287 }
288 },
289 } as any;
290 }
291
292 function startReadingFromDebugChannelReadableStream(
293 request: Request,
294 stream: ReadableStream,
295 ): void {
296 const reader = stream.getReader();
297 const stringDecoder = createStringDecoder();
298 let stringBuffer = '';
299 function progress({
300 done,
301 value,
302 }: {
303 done: boolean,
304 value: ?any,
305 ...
306 }): void | Promise<void> {
307 const buffer: Uint8Array = value as any;
308 stringBuffer += done
309 ? readFinalStringChunk(stringDecoder, new Uint8Array(0))
310 : readPartialStringChunk(stringDecoder, buffer);
311 const messages = stringBuffer.split('\n');
312 for (let i = 0; i < messages.length - 1; i++) {
313 resolveDebugMessage(request, messages[i]);
314 }
315 stringBuffer = messages[messages.length - 1];
316 if (done) {
317 closeDebugChannel(request);
318 return;
319 }
320 return reader.read().then(progress).catch(error);
321 }
322 function error(e: any) {
323 abort(
324 request,
325 new Error('Lost connection to the Debug Channel.', {
326 cause: e,
327 }),
328 );
329 }
330 reader.read().then(progress).catch(error);
331 }
332
333 function renderToReadableStream(
334 model: ReactClientValue,
335 webpackMap: ClientManifest,
336 options?: Omit<Options, 'debugChannel'> & {
337 debugChannel?: {readable?: ReadableStream, writable?: WritableStream, ...},
338 signal?: AbortSignal,
339 },
340 ): ReadableStream {
341 const debugChannelReadable =
342 __DEV__ && options && options.debugChannel
343 ? options.debugChannel.readable
344 : undefined;
345 const debugChannelWritable =
346 __DEV__ && options && options.debugChannel
347 ? options.debugChannel.writable
348 : undefined;
349 const request = createRequest(
350 model,
351 webpackMap,
352 options ? options.onError : undefined,
353 options ? options.identifierPrefix : undefined,
354 options ? options.temporaryReferences : undefined,
355 options ? options.startTime : undefined,
356 __DEV__ && options ? options.environmentName : undefined,
357 __DEV__ && options ? options.filterStackFrame : undefined,
358 debugChannelReadable !== undefined,
359 );
360 if (options && options.signal) {
361 attachAbortSignal(request, options.signal);
362 }
363 if (debugChannelWritable !== undefined) {
364 let debugWritable: Writable;
365 const debugStream = new ReadableStream(
366 {
367 type: 'bytes',
368 start: (controller): ?Promise<void> => {
369 debugWritable =
370 createFakeWritableFromReadableStreamController(controller);
371 },
372 pull: (controller): ?Promise<void> => {
373 startFlowingDebug(request, debugWritable);
374 },
375 },
376 // $FlowFixMe[prop-missing] size() methods are not allowed on byte streams.
377 // $FlowFixMe[incompatible-type]
378 {highWaterMark: 0},
379 );
380 debugStream.pipeTo(debugChannelWritable);
381 }
382 if (debugChannelReadable !== undefined) {
383 startReadingFromDebugChannelReadableStream(request, debugChannelReadable);
384 }
385 let writable: Writable;
386 const stream = new ReadableStream(
387 {
388 type: 'bytes',
389 start: (controller): ?Promise<void> => {
390 writable = createFakeWritableFromReadableStreamController(controller);
391 startWork(request);
392 },
393 pull: (controller): ?Promise<void> => {
394 startFlowing(request, writable);
395 },
396 cancel: (reason): ?Promise<void> => {
397 stopFlowing(request);
398 abort(request, reason);
399 },
400 },
401 // $FlowFixMe[prop-missing] size() methods are not allowed on byte streams.
402 // $FlowFixMe[incompatible-type]
403 {highWaterMark: 0},
404 );
405 return stream;
406 }
407
408 function createFakeWritableFromNodeReadable(readable: any): Writable {
409 // The current host config expects a Writable so we create
410 // a fake writable for now to push into the Readable.
411 return {
412 write(chunk: string | Uint8Array) {
413 return readable.push(chunk);
414 },
415 end() {
416 readable.push(null);
417 },
418 destroy(error) {
419 readable.destroy(error);
420 },
421 } as any;
422 }
423
424 type PrerenderOptions = {
425 environmentName?: string | (() => string),
426 filterStackFrame?: (url: string, functionName: string) => boolean,
427 onError?: (error: mixed) => void,
428 identifierPrefix?: string,
429 temporaryReferences?: TemporaryReferenceSet,
430 signal?: AbortSignal,
431 startTime?: number,
432 };
433
434 type StaticResult = {
435 prelude: Readable,
436 };
437
438 function prerenderToNodeStream(
439 model: ReactClientValue,
440 webpackMap: ClientManifest,
441 options?: PrerenderOptions,
442 ): Promise<StaticResult> {
443 return new Promise((resolve, reject) => {
444 const onFatalError = reject;
445 function onAllReady() {
446 const readable: Readable = new Readable({
447 read() {
448 startFlowing(request, writable);
449 },
450 });
451 const writable = createFakeWritableFromNodeReadable(readable);
452 resolve({prelude: readable});
453 }
454
455 const request = createPrerenderRequest(
456 model,
457 webpackMap,
458 onAllReady,
459 onFatalError,
460 options ? options.onError : undefined,
461 options ? options.identifierPrefix : undefined,
462 options ? options.temporaryReferences : undefined,
463 options ? options.startTime : undefined,
464 __DEV__ && options ? options.environmentName : undefined,
465 __DEV__ && options ? options.filterStackFrame : undefined,
466 false,
467 );
468 if (options && options.signal) {
469 attachAbortSignal(request, options.signal);
470 }
471 startWork(request);
472 });
473 }
474
475 function prerender(
476 model: ReactClientValue,
477 webpackMap: ClientManifest,
478 options?: Options & {
479 signal?: AbortSignal,
480 },
481 ): Promise<{
482 prelude: ReadableStream,
483 }> {
484 return new Promise((resolve, reject) => {
485 const onFatalError = reject;
486 function onAllReady() {
487 let writable: Writable;
488 const stream = new ReadableStream(
489 {
490 type: 'bytes',
491 start: (controller): ?Promise<void> => {
492 writable =
493 createFakeWritableFromReadableStreamController(controller);
494 },
495 pull: (controller): ?Promise<void> => {
496 startFlowing(request, writable);
497 },
498 cancel: (reason): ?Promise<void> => {
499 stopFlowing(request);
500 abort(request, reason);
501 },
502 },
503 // $FlowFixMe[prop-missing] size() methods are not allowed on byte streams.
504 // $FlowFixMe[incompatible-type]
505 {highWaterMark: 0},
506 );
507 resolve({prelude: stream});
508 }
509 const request = createPrerenderRequest(
510 model,
511 webpackMap,
512 onAllReady,
513 onFatalError,
514 options ? options.onError : undefined,
515 options ? options.identifierPrefix : undefined,
516 options ? options.temporaryReferences : undefined,
517 options ? options.startTime : undefined,
518 __DEV__ && options ? options.environmentName : undefined,
519 __DEV__ && options ? options.filterStackFrame : undefined,
520 false,
521 );
522 if (options && options.signal) {
523 attachAbortSignal(request, options.signal);
524 }
525 startWork(request);
526 });
527 }
528
529 type PendingFile = {
530 name: string,
531 file: FileHandle,
532 complete: boolean,
533 // Lazily allocated when a text field arrives after this file's 'file'
534 // event but before its (deferred) 'end' event. Stored as flat
535 // [name1, value1, name2, value2, ...] pairs.
536 queuedFields: null | Array<string>,
537 next: null | PendingFile,
538 };
539
540 function decodeReplyFromBusboy<T>(
541 busboyStream: Busboy,
542 webpackMap: ServerManifest,
543 options?: {
544 temporaryReferences?: TemporaryReferenceSet,
545 arraySizeLimit?: number,
546 },
547 ): Thenable<T> {
548 const response = createResponse(
549 webpackMap,
550 '',
551 options ? options.temporaryReferences : undefined,
552 undefined,
553 options ? options.arraySizeLimit : undefined,
554 );
555
556 // Linked list of pending files in arrival (payload) order. Text fields that
557 // arrive while a file is in flight are queued on the tail file's
558 // `queuedFields` so they can be resolved together when that file completes.
559 // Fields that arrive while the list is empty bypass it and resolve
560 // immediately. This makes the backing FormData's insertion order match the
561 // payload's entry order.
562 let head: null | PendingFile = null;
563 let tail: null | PendingFile = null;
564 let bodyFinished = false;
565 let closed = false;
566
567 function flush() {
568 while (head !== null) {
569 const current = head;
570 if (!current.complete) {
571 // This file is still streaming. Hold later files and fields until it
572 // completes so the backing FormData reflects payload order.
573 return;
574 }
575 try {
576 resolveFileComplete(response, current.name, current.file);
577 const queuedFields = current.queuedFields;
578 if (queuedFields !== null) {
579 for (let i = 0; i < queuedFields.length; i += 2) {
580 resolveField(response, queuedFields[i], queuedFields[i + 1]);
581 }
582 }
583 } catch (error) {
584 busboyStream.destroy(error);
585 return;
586 }
587 head = current.next;
588 }
589 tail = null;
590 if (bodyFinished && !closed) {
591 closed = true;
592 close(response);
593 }
594 }
595
596 busboyStream.on('field', (name, value) => {
597 if (tail !== null) {
598 // A file is in flight; queue the field on the tail (most recent) pending
599 // file so it resolves after that file, preserving payload order.
600 if (tail.queuedFields === null) {
601 tail.queuedFields = [];
602 }
603 tail.queuedFields.push(name, value);
604 } else {
605 try {
606 resolveField(response, name, value);
607 } catch (error) {
608 busboyStream.destroy(error);
609 }
610 }
611 });
612 busboyStream.on('file', (name, value, {filename, encoding, mimeType}) => {
613 if (encoding.toLowerCase() === 'base64') {
614 busboyStream.destroy(
615 new Error(
616 "React doesn't accept base64 encoded file uploads because we don't expect " +
617 "form data passed from a browser to ever encode data that way. If that's " +
618 'the wrong assumption, we can easily fix it.',
619 ),
620 );
621 return;
622 }
623 const file = resolveFileInfo(response, name, filename, mimeType);
624 const pendingFile: PendingFile = {
625 name,
626 file,
627 complete: false,
628 queuedFields: null,
629 next: null,
630 };
631 if (tail === null) {
632 head = pendingFile;
633 } else {
634 tail.next = pendingFile;
635 }
636 tail = pendingFile;
637 value.on('data', chunk => {
638 try {
639 resolveFileChunk(response, file, chunk);
640 } catch (error) {
641 busboyStream.destroy(error);
642 }
643 });
644 value.on('error', error => {
645 busboyStream.destroy(error);
646 });
647 value.on('end', () => {
648 pendingFile.complete = true;
649 flush();
650 });
651 });
652 busboyStream.on('finish', () => {
653 bodyFinished = true;
654 flush();
655 if (!closed) {
656 // Invariant: busboy delays 'finish' until every file's 'end' event has
657 // fired, so the flush above should always close the response.
658 reportGlobalError(
659 response,
660 new Error('Reply finished with incomplete file part.'),
661 );
662 }
663 });
664 busboyStream.on('error', err => {
665 reportGlobalError(
666 response,
667 // $FlowFixMe[incompatible-call] types Error and mixed are incompatible
668 // $FlowFixMe[incompatible-type]
669 err,
670 );
671 });
672 return getRoot(response);
673 }
674
675 function decodeReply<T>(
676 body: string | FormData,
677 webpackMap: ServerManifest,
678 options?: {
679 temporaryReferences?: TemporaryReferenceSet,
680 arraySizeLimit?: number,
681 },
682 ): Thenable<T> {
683 if (typeof body === 'string') {
684 const form = new FormData();
685 form.append('0', body);
686 body = form;
687 }
688 const response = createResponse(
689 webpackMap,
690 '',
691 options ? options.temporaryReferences : undefined,
692 body,
693 options ? options.arraySizeLimit : undefined,
694 );
695 const root = getRoot<T>(response);
696 close(response);
697 return root;
698 }
699
700 function decodeReplyFromAsyncIterable<T>(
701 iterable: AsyncIterable<[string, string | File]>,
702 webpackMap: ServerManifest,
703 options?: {
704 temporaryReferences?: TemporaryReferenceSet,
705 arraySizeLimit?: number,
706 },
707 ): Thenable<T> {
708 const iterator: AsyncIterator<[string, string | File]> =
709 iterable[ASYNC_ITERATOR]();
710
711 const response = createResponse(
712 webpackMap,
713 '',
714 options ? options.temporaryReferences : undefined,
715 undefined,
716 options ? options.arraySizeLimit : undefined,
717 );
718
719 function progress(
720 entry:
721 | {done: false, +value: [string, string | File], ...}
722 | {done: true, +value: void, ...},
723 ) {
724 if (entry.done) {
725 close(response);
726 } else {
727 const [name, value] = entry.value;
728 if (typeof value === 'string') {
729 resolveField(response, name, value);
730 } else {
731 resolveFile(response, name, value);
732 }
733 iterator.next().then(progress, error);
734 }
735 }
736 function error(reason: Error) {
737 reportGlobalError(response, reason);
738 if (typeof (iterator as any).throw === 'function') {
739 // The iterator protocol doesn't necessarily include this but a generator do.
740 // $FlowFixMe[prop-missing] should be able to pass mixed
741 iterator.throw(reason).then(noop, noop);
742 }
743 }
744
745 iterator.next().then(progress, error);
746
747 return getRoot(response);
748 }
749
750 export {
751 renderToReadableStream,
752 renderToPipeableStream,
753 prerender,
754 prerenderToNodeStream,
755 decodeReply,
756 decodeReplyFromBusboy,
757 decodeReplyFromAsyncIterable,
758 decodeAction,
759 decodeFormState,
760 };