main
js 379 lines 10.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 {Busboy} from 'busboy';
16 import type {Writable} from 'stream';
17 import type {Thenable} from 'shared/ReactTypes';
18
19 import type {Duplex} from 'stream';
20
21 import {Readable} from 'stream';
22
23 import {
24 createRequest,
25 startWork,
26 startFlowing,
27 startFlowingDebug,
28 stopFlowing,
29 abort,
30 resolveDebugMessage,
31 closeDebugChannel,
32 } from 'react-server/src/ReactFlightServer';
33
34 import {
35 createResponse,
36 reportGlobalError,
37 close,
38 resolveField,
39 resolveFileInfo,
40 resolveFileChunk,
41 resolveFileComplete,
42 getRoot,
43 } from 'react-server/src/ReactFlightReplyServer';
44
45 import {
46 decodeAction,
47 decodeFormState,
48 } from 'react-server/src/ReactFlightActionServer';
49
50 export {
51 registerServerReference,
52 registerClientReference,
53 } from '../ReactFlightFBReferences';
54
55 // Buffer-based string decoder helpers. The FB server environment does not
56 // have TextDecoder, so we use Buffer.toString('utf8') instead.
57 type BufferDecoder = {_pendingBytes: Array<Uint8Array>};
58
59 function createStringDecoder(): BufferDecoder {
60 return {_pendingBytes: []};
61 }
62
63 function readPartialStringChunk(
64 decoder: BufferDecoder,
65 buffer: Uint8Array,
66 ): string {
67 return Buffer.from(
68 buffer.buffer,
69 buffer.byteOffset,
70 buffer.byteLength,
71 ).toString('utf8');
72 }
73
74 function readFinalStringChunk(
75 decoder: BufferDecoder,
76 buffer: Uint8Array,
77 ): string {
78 return Buffer.from(
79 buffer.buffer,
80 buffer.byteOffset,
81 buffer.byteLength,
82 ).toString('utf8');
83 }
84
85 import type {TemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences';
86
87 export {createTemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences';
88
89 export type {TemporaryReferenceSet};
90
91 function createDrainHandler(destination: Destination, request: Request) {
92 return () => startFlowing(request, destination);
93 }
94
95 function createCancelHandler(request: Request, reason: string) {
96 return () => {
97 stopFlowing(request);
98 abort(request, new Error(reason));
99 };
100 }
101
102 function startReadingFromDebugChannelReadable(
103 request: Request,
104 stream: Readable | WebSocket,
105 ): void {
106 const stringDecoder = createStringDecoder();
107 let lastWasPartial = false;
108 let stringBuffer = '';
109 function onData(chunk: string | Uint8Array) {
110 if (typeof chunk === 'string') {
111 if (lastWasPartial) {
112 stringBuffer += readFinalStringChunk(stringDecoder, new Uint8Array(0));
113 lastWasPartial = false;
114 }
115 stringBuffer += chunk;
116 } else {
117 const buffer: Uint8Array = chunk as any;
118 stringBuffer += readPartialStringChunk(stringDecoder, buffer);
119 lastWasPartial = true;
120 }
121 const messages = stringBuffer.split('\n');
122 for (let i = 0; i < messages.length - 1; i++) {
123 resolveDebugMessage(request, messages[i]);
124 }
125 stringBuffer = messages[messages.length - 1];
126 }
127 function onError(error: mixed) {
128 abort(
129 request,
130 new Error('Lost connection to the Debug Channel.', {
131 cause: error,
132 }),
133 );
134 }
135 function onClose() {
136 closeDebugChannel(request);
137 }
138 if (
139 // $FlowFixMe[method-unbinding]
140 typeof stream.addEventListener === 'function' &&
141 // $FlowFixMe[method-unbinding]
142 typeof stream.binaryType === 'string'
143 ) {
144 const ws: WebSocket = stream as any;
145 ws.binaryType = 'arraybuffer';
146 ws.addEventListener('message', event => {
147 // $FlowFixMe[incompatible-type]
148 onData(event.data);
149 });
150 ws.addEventListener('error', event => {
151 // $FlowFixMe[prop-missing]
152 onError(event.error);
153 });
154 ws.addEventListener('close', onClose);
155 } else {
156 const readable: Readable = stream as any;
157 readable.on('data', onData);
158 readable.on('error', onError);
159 readable.on('end', onClose);
160 }
161 }
162
163 type Options = {
164 debugChannel?: Readable | Writable | Duplex | WebSocket,
165 environmentName?: string | (() => string),
166 filterStackFrame?: (url: string, functionName: string) => boolean,
167 onError?: (error: mixed) => void,
168 identifierPrefix?: string,
169 temporaryReferences?: TemporaryReferenceSet,
170 startTime?: number,
171 };
172
173 type PipeableStream = {
174 abort(reason: mixed): void,
175 pipe<T: Writable>(destination: T): T,
176 };
177
178 function renderToPipeableStream(
179 model: ReactClientValue,
180 options?: Options,
181 ): PipeableStream {
182 const debugChannel = __DEV__ && options ? options.debugChannel : undefined;
183 const debugChannelReadable: void | Readable | WebSocket =
184 __DEV__ &&
185 debugChannel !== undefined &&
186 // $FlowFixMe[method-unbinding]
187 (typeof debugChannel.read === 'function' ||
188 typeof debugChannel.readyState === 'number')
189 ? (debugChannel as any)
190 : undefined;
191 const debugChannelWritable: void | Writable =
192 __DEV__ && debugChannel !== undefined
193 ? // $FlowFixMe[method-unbinding]
194 typeof debugChannel.write === 'function'
195 ? (debugChannel as any)
196 : // $FlowFixMe[method-unbinding]
197 typeof debugChannel.send === 'function'
198 ? createFakeWritableFromWebSocket(debugChannel as any)
199 : undefined
200 : undefined;
201 const request = createRequest(
202 model,
203 null,
204 options ? options.onError : undefined,
205 options ? options.identifierPrefix : undefined,
206 options ? options.temporaryReferences : undefined,
207 options ? options.startTime : undefined,
208 __DEV__ && options ? options.environmentName : undefined,
209 __DEV__ && options ? options.filterStackFrame : undefined,
210 debugChannelReadable !== undefined,
211 );
212 let hasStartedFlowing = false;
213 startWork(request);
214 if (debugChannelWritable !== undefined) {
215 startFlowingDebug(request, debugChannelWritable);
216 }
217 if (debugChannelReadable !== undefined) {
218 startReadingFromDebugChannelReadable(request, debugChannelReadable);
219 }
220 return {
221 pipe<T: Writable>(destination: T): T {
222 if (hasStartedFlowing) {
223 throw new Error(
224 'React currently only supports piping to one writable stream.',
225 );
226 }
227 hasStartedFlowing = true;
228 startFlowing(request, destination);
229 destination.on('drain', createDrainHandler(destination, request));
230 destination.on(
231 'error',
232 createCancelHandler(
233 request,
234 'The destination stream errored while writing data.',
235 ),
236 );
237 // We don't close until the debug channel closes.
238 if (!__DEV__ || debugChannelReadable === undefined) {
239 destination.on(
240 'close',
241 createCancelHandler(request, 'The destination stream closed early.'),
242 );
243 }
244 return destination;
245 },
246 abort(reason: mixed) {
247 abort(request, reason);
248 },
249 };
250 }
251
252 function createFakeWritableFromWebSocket(webSocket: WebSocket): Writable {
253 return {
254 write(chunk: string | Uint8Array) {
255 webSocket.send(chunk as any);
256 return true;
257 },
258 end() {
259 webSocket.close();
260 },
261 destroy(reason) {
262 if (typeof reason === 'object' && reason !== null) {
263 reason = reason.message;
264 }
265 if (typeof reason === 'string') {
266 webSocket.close(1011, reason);
267 } else {
268 webSocket.close(1011);
269 }
270 },
271 } as any;
272 }
273
274 function decodeReplyFromBusboy<T>(
275 busboyStream: Busboy,
276 options?: {
277 temporaryReferences?: TemporaryReferenceSet,
278 arraySizeLimit?: number,
279 },
280 ): Thenable<T> {
281 const response = createResponse(
282 null,
283 '',
284 options ? options.temporaryReferences : undefined,
285 undefined,
286 options ? options.arraySizeLimit : undefined,
287 );
288 let pendingFiles = 0;
289 const queuedFields: Array<string> = [];
290 busboyStream.on('field', (name, value) => {
291 if (pendingFiles > 0) {
292 // Because the 'end' event fires two microtasks after the next 'field'
293 // we would resolve files and fields out of order. To handle this properly
294 // we queue any fields we receive until the previous file is done.
295 queuedFields.push(name, value);
296 } else {
297 try {
298 resolveField(response, name, value);
299 } catch (error) {
300 busboyStream.destroy(error);
301 }
302 }
303 });
304 busboyStream.on('file', (name, value, {filename, encoding, mimeType}) => {
305 if (encoding.toLowerCase() === 'base64') {
306 busboyStream.destroy(
307 new Error(
308 "React doesn't accept base64 encoded file uploads because we don't expect " +
309 "form data passed from a browser to ever encode data that way. If that's " +
310 'the wrong assumption, we can easily fix it.',
311 ),
312 );
313 return;
314 }
315 pendingFiles++;
316 const file = resolveFileInfo(response, name, filename, mimeType);
317 value.on('data', chunk => {
318 resolveFileChunk(response, file, chunk);
319 });
320 value.on('end', () => {
321 try {
322 resolveFileComplete(response, name, file);
323 pendingFiles--;
324 if (pendingFiles === 0) {
325 // Release any queued fields
326 for (let i = 0; i < queuedFields.length; i += 2) {
327 resolveField(response, queuedFields[i], queuedFields[i + 1]);
328 }
329 queuedFields.length = 0;
330 }
331 } catch (error) {
332 busboyStream.destroy(error);
333 }
334 });
335 });
336 busboyStream.on('finish', () => {
337 close(response);
338 });
339 busboyStream.on('error', err => {
340 reportGlobalError(
341 response,
342 // $FlowFixMe[incompatible-type] types Error and mixed are incompatible
343 err,
344 );
345 });
346 return getRoot(response);
347 }
348
349 function decodeReply<T>(
350 body: string | FormData,
351 options?: {
352 temporaryReferences?: TemporaryReferenceSet,
353 arraySizeLimit?: number,
354 },
355 ): Thenable<T> {
356 if (typeof body === 'string') {
357 const form = new FormData();
358 form.append('0', body);
359 body = form;
360 }
361 const response = createResponse(
362 null,
363 '',
364 options ? options.temporaryReferences : undefined,
365 body,
366 options ? options.arraySizeLimit : undefined,
367 );
368 const root = getRoot<T>(response);
369 close(response);
370 return root;
371 }
372
373 export {
374 renderToPipeableStream,
375 decodeReply,
376 decodeReplyFromBusboy,
377 decodeAction,
378 decodeFormState,
379 };