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