main
js 500 lines 13.8 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 './ReactFlightServerConfigESMBundler';
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 {
26 createRequest,
27 createPrerenderRequest,
28 startWork,
29 startFlowing,
30 startFlowingDebug,
31 stopFlowing,
32 abort,
33 attachAbortSignal,
34 resolveDebugMessage,
35 closeDebugChannel,
36 } from 'react-server/src/ReactFlightServer';
37
38 import {
39 createResponse,
40 reportGlobalError,
41 close,
42 resolveField,
43 resolveFileInfo,
44 resolveFileChunk,
45 resolveFileComplete,
46 getRoot,
47 } from 'react-server/src/ReactFlightReplyServer';
48
49 import {
50 decodeAction,
51 decodeFormState,
52 } from 'react-server/src/ReactFlightActionServer';
53
54 export {
55 registerServerReference,
56 registerClientReference,
57 } from '../ReactFlightESMReferences';
58
59 import {
60 createStringDecoder,
61 readPartialStringChunk,
62 readFinalStringChunk,
63 } from 'react-client/src/ReactFlightClientStreamConfigNode';
64
65 import type {TemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences';
66 import type {FileHandle} from 'react-server/src/ReactFlightReplyServer';
67
68 export {createTemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences';
69
70 export type {TemporaryReferenceSet};
71
72 function createDrainHandler(destination: Destination, request: Request) {
73 return () => startFlowing(request, destination);
74 }
75
76 function createCancelHandler(request: Request, reason: string) {
77 return () => {
78 stopFlowing(request);
79 abort(request, new Error(reason));
80 };
81 }
82
83 function startReadingFromDebugChannelReadable(
84 request: Request,
85 stream: Readable | WebSocket,
86 ): void {
87 const stringDecoder = createStringDecoder();
88 let lastWasPartial = false;
89 let stringBuffer = '';
90 function onData(chunk: string | Uint8Array) {
91 if (typeof chunk === 'string') {
92 if (lastWasPartial) {
93 stringBuffer += readFinalStringChunk(stringDecoder, new Uint8Array(0));
94 lastWasPartial = false;
95 }
96 stringBuffer += chunk;
97 } else {
98 const buffer: Uint8Array = chunk as any;
99 stringBuffer += readPartialStringChunk(stringDecoder, buffer);
100 lastWasPartial = true;
101 }
102 const messages = stringBuffer.split('\n');
103 for (let i = 0; i < messages.length - 1; i++) {
104 resolveDebugMessage(request, messages[i]);
105 }
106 stringBuffer = messages[messages.length - 1];
107 }
108 function onError(error: mixed) {
109 abort(
110 request,
111 new Error('Lost connection to the Debug Channel.', {
112 cause: error,
113 }),
114 );
115 }
116 function onClose() {
117 closeDebugChannel(request);
118 }
119 if (
120 // $FlowFixMe[method-unbinding]
121 typeof stream.addEventListener === 'function' &&
122 // $FlowFixMe[method-unbinding]
123 typeof stream.binaryType === 'string'
124 ) {
125 const ws: WebSocket = stream as any;
126 ws.binaryType = 'arraybuffer';
127 ws.addEventListener('message', event => {
128 // $FlowFixMe[incompatible-type]
129 onData(event.data);
130 });
131 ws.addEventListener('error', event => {
132 // $FlowFixMe[prop-missing]
133 onError(event.error);
134 });
135 ws.addEventListener('close', onClose);
136 } else {
137 const readable: Readable = stream as any;
138 readable.on('data', onData);
139 readable.on('error', onError);
140 readable.on('end', onClose);
141 }
142 }
143
144 type Options = {
145 debugChannel?: Readable | Writable | Duplex | WebSocket,
146 environmentName?: string | (() => string),
147 filterStackFrame?: (url: string, functionName: string) => boolean,
148 onError?: (error: mixed) => void,
149 identifierPrefix?: string,
150 temporaryReferences?: TemporaryReferenceSet,
151 startTime?: number,
152 };
153
154 type PipeableStream = {
155 abort(reason: mixed): void,
156 pipe<T: Writable>(destination: T): T,
157 };
158
159 function renderToPipeableStream(
160 model: ReactClientValue,
161 moduleBasePath: ClientManifest,
162 options?: Options,
163 ): PipeableStream {
164 const debugChannel = __DEV__ && options ? options.debugChannel : undefined;
165 const debugChannelReadable: void | Readable | WebSocket =
166 __DEV__ &&
167 debugChannel !== undefined &&
168 // $FlowFixMe[method-unbinding]
169 (typeof debugChannel.read === 'function' ||
170 typeof debugChannel.readyState === 'number')
171 ? (debugChannel as any)
172 : undefined;
173 const debugChannelWritable: void | Writable =
174 __DEV__ && debugChannel !== undefined
175 ? // $FlowFixMe[method-unbinding]
176 typeof debugChannel.write === 'function'
177 ? (debugChannel as any)
178 : // $FlowFixMe[method-unbinding]
179 typeof debugChannel.send === 'function'
180 ? createFakeWritableFromWebSocket(debugChannel as any)
181 : undefined
182 : undefined;
183 const request = createRequest(
184 model,
185 moduleBasePath,
186 options ? options.onError : undefined,
187 options ? options.identifierPrefix : undefined,
188 options ? options.temporaryReferences : undefined,
189 options ? options.startTime : undefined,
190 __DEV__ && options ? options.environmentName : undefined,
191 __DEV__ && options ? options.filterStackFrame : undefined,
192 debugChannelReadable !== undefined,
193 );
194 let hasStartedFlowing = false;
195 startWork(request);
196 if (debugChannelWritable !== undefined) {
197 startFlowingDebug(request, debugChannelWritable);
198 }
199 if (debugChannelReadable !== undefined) {
200 startReadingFromDebugChannelReadable(request, debugChannelReadable);
201 }
202 return {
203 pipe<T: Writable>(destination: T): T {
204 if (hasStartedFlowing) {
205 throw new Error(
206 'React currently only supports piping to one writable stream.',
207 );
208 }
209 hasStartedFlowing = true;
210 startFlowing(request, destination);
211 destination.on('drain', createDrainHandler(destination, request));
212 destination.on(
213 'error',
214 createCancelHandler(
215 request,
216 'The destination stream errored while writing data.',
217 ),
218 );
219 // We don't close until the debug channel closes.
220 if (!__DEV__ || debugChannelReadable === undefined) {
221 destination.on(
222 'close',
223 createCancelHandler(request, 'The destination stream closed early.'),
224 );
225 }
226 return destination;
227 },
228 abort(reason: mixed) {
229 abort(request, reason);
230 },
231 };
232 }
233
234 function createFakeWritableFromWebSocket(webSocket: WebSocket): Writable {
235 return {
236 write(chunk: string | Uint8Array) {
237 webSocket.send(chunk as any);
238 return true;
239 },
240 end() {
241 webSocket.close();
242 },
243 destroy(reason) {
244 if (typeof reason === 'object' && reason !== null) {
245 reason = reason.message;
246 }
247 if (typeof reason === 'string') {
248 webSocket.close(1011, reason);
249 } else {
250 webSocket.close(1011);
251 }
252 },
253 } as any;
254 }
255
256 function createFakeWritable(readable: any): Writable {
257 // The current host config expects a Writable so we create
258 // a fake writable for now to push into the Readable.
259 return {
260 write(chunk: string | Uint8Array) {
261 return readable.push(chunk);
262 },
263 end() {
264 readable.push(null);
265 },
266 destroy(error) {
267 readable.destroy(error);
268 },
269 } as any;
270 }
271
272 type PrerenderOptions = {
273 environmentName?: string | (() => string),
274 filterStackFrame?: (url: string, functionName: string) => boolean,
275 onError?: (error: mixed) => void,
276 identifierPrefix?: string,
277 temporaryReferences?: TemporaryReferenceSet,
278 signal?: AbortSignal,
279 startTime?: number,
280 };
281
282 type StaticResult = {
283 prelude: Readable,
284 };
285
286 function prerenderToNodeStream(
287 model: ReactClientValue,
288 moduleBasePath: ClientManifest,
289 options?: PrerenderOptions,
290 ): Promise<StaticResult> {
291 return new Promise((resolve, reject) => {
292 const onFatalError = reject;
293 function onAllReady() {
294 const readable: Readable = new Readable({
295 read() {
296 startFlowing(request, writable);
297 },
298 });
299 const writable = createFakeWritable(readable);
300 resolve({prelude: readable});
301 }
302
303 const request = createPrerenderRequest(
304 model,
305 moduleBasePath,
306 onAllReady,
307 onFatalError,
308 options ? options.onError : undefined,
309 options ? options.identifierPrefix : undefined,
310 options ? options.temporaryReferences : undefined,
311 options ? options.startTime : undefined,
312 __DEV__ && options ? options.environmentName : undefined,
313 __DEV__ && options ? options.filterStackFrame : undefined,
314 false,
315 );
316 if (options && options.signal) {
317 attachAbortSignal(request, options.signal);
318 }
319 startWork(request);
320 });
321 }
322
323 type PendingFile = {
324 name: string,
325 file: FileHandle,
326 complete: boolean,
327 // Lazily allocated when a text field arrives after this file's 'file'
328 // event but before its (deferred) 'end' event. Stored as flat
329 // [name1, value1, name2, value2, ...] pairs.
330 queuedFields: null | Array<string>,
331 next: null | PendingFile,
332 };
333
334 function decodeReplyFromBusboy<T>(
335 busboyStream: Busboy,
336 moduleBasePath: ServerManifest,
337 options?: {
338 temporaryReferences?: TemporaryReferenceSet,
339 arraySizeLimit?: number,
340 },
341 ): Thenable<T> {
342 const response = createResponse(
343 moduleBasePath,
344 '',
345 options ? options.temporaryReferences : undefined,
346 undefined,
347 options ? options.arraySizeLimit : undefined,
348 );
349
350 // Linked list of pending files in arrival (payload) order. Text fields that
351 // arrive while a file is in flight are queued on the tail file's
352 // `queuedFields` so they can be resolved together when that file completes.
353 // Fields that arrive while the list is empty bypass it and resolve
354 // immediately. This makes the backing FormData's insertion order match the
355 // payload's entry order.
356 let head: null | PendingFile = null;
357 let tail: null | PendingFile = null;
358 let bodyFinished = false;
359 let closed = false;
360
361 function flush() {
362 while (head !== null) {
363 const current = head;
364 if (!current.complete) {
365 // This file is still streaming. Hold later files and fields until it
366 // completes so the backing FormData reflects payload order.
367 return;
368 }
369 try {
370 resolveFileComplete(response, current.name, current.file);
371 const queuedFields = current.queuedFields;
372 if (queuedFields !== null) {
373 for (let i = 0; i < queuedFields.length; i += 2) {
374 resolveField(response, queuedFields[i], queuedFields[i + 1]);
375 }
376 }
377 } catch (error) {
378 busboyStream.destroy(error);
379 return;
380 }
381 head = current.next;
382 }
383 tail = null;
384 if (bodyFinished && !closed) {
385 closed = true;
386 close(response);
387 }
388 }
389
390 busboyStream.on('field', (name, value) => {
391 if (tail !== null) {
392 // A file is in flight; queue the field on the tail (most recent) pending
393 // file so it resolves after that file, preserving payload order.
394 if (tail.queuedFields === null) {
395 tail.queuedFields = [];
396 }
397 tail.queuedFields.push(name, value);
398 } else {
399 try {
400 resolveField(response, name, value);
401 } catch (error) {
402 busboyStream.destroy(error);
403 }
404 }
405 });
406 busboyStream.on('file', (name, value, {filename, encoding, mimeType}) => {
407 if (encoding.toLowerCase() === 'base64') {
408 busboyStream.destroy(
409 new Error(
410 "React doesn't accept base64 encoded file uploads because we don't expect " +
411 "form data passed from a browser to ever encode data that way. If that's " +
412 'the wrong assumption, we can easily fix it.',
413 ),
414 );
415 return;
416 }
417 const file = resolveFileInfo(response, name, filename, mimeType);
418 const pendingFile: PendingFile = {
419 name,
420 file,
421 complete: false,
422 queuedFields: null,
423 next: null,
424 };
425 if (tail === null) {
426 head = pendingFile;
427 } else {
428 tail.next = pendingFile;
429 }
430 tail = pendingFile;
431 value.on('data', chunk => {
432 try {
433 resolveFileChunk(response, file, chunk);
434 } catch (error) {
435 busboyStream.destroy(error);
436 }
437 });
438 value.on('error', error => {
439 busboyStream.destroy(error);
440 });
441 value.on('end', () => {
442 pendingFile.complete = true;
443 flush();
444 });
445 });
446 busboyStream.on('finish', () => {
447 bodyFinished = true;
448 flush();
449 if (!closed) {
450 // Invariant: busboy delays 'finish' until every file's 'end' event has
451 // fired, so the flush above should always close the response.
452 reportGlobalError(
453 response,
454 new Error('Reply finished with incomplete file part.'),
455 );
456 }
457 });
458 busboyStream.on('error', err => {
459 reportGlobalError(
460 response,
461 // $FlowFixMe[incompatible-type] types Error and mixed are incompatible
462 err,
463 );
464 });
465 return getRoot(response);
466 }
467
468 function decodeReply<T>(
469 body: string | FormData,
470 moduleBasePath: ServerManifest,
471 options?: {
472 temporaryReferences?: TemporaryReferenceSet,
473 arraySizeLimit?: number,
474 },
475 ): Thenable<T> {
476 if (typeof body === 'string') {
477 const form = new FormData();
478 form.append('0', body);
479 body = form;
480 }
481 const response = createResponse(
482 moduleBasePath,
483 '',
484 options ? options.temporaryReferences : undefined,
485 body,
486 options ? options.arraySizeLimit : undefined,
487 );
488 const root = getRoot<T>(response);
489 close(response);
490 return root;
491 }
492
493 export {
494 renderToPipeableStream,
495 prerenderToNodeStream,
496 decodeReply,
497 decodeReplyFromBusboy,
498 decodeAction,
499 decodeFormState,
500 };