Files
react/packages/react-flight-server-fb/src/server/ReactFlightDOMServerNode.js
T
Xinzi ZhouandGitHub 4c56e37e58 Add react-flight-server-fb package for Meta's internal bundler (#36309)
<!--
  Thanks for submitting a pull request!
We appreciate you spending the time to work on these changes. Please
provide enough information so that others can review your pull request.
The three fields below are mandatory.

Before submitting a pull request, please make sure the following is
done:

1. Fork [the repository](https://github.com/facebook/react) and create
your branch from `main`.
  2. Run `yarn` in the repository root.
3. If you've fixed a bug or added code that should be tested, add tests!
4. Ensure the test suite passes (`yarn test`). Tip: `yarn test --watch
TestName` is helpful in development.
5. Run `yarn test --prod` to test in the production environment. It
supports the same options as `yarn test`.
6. If you need a debugger, run `yarn test --debug --watch TestName`,
open `chrome://inspect`, and press "Inspect".
7. Format your code with
[prettier](https://github.com/prettier/prettier) (`yarn prettier`).
8. Make sure your code lints (`yarn lint`). Tip: `yarn linc` to only
check changed files.
  9. Run the [Flow](https://flowtype.org/) type checks (`yarn flow`).
  10. If you haven't already, complete the CLA.

Learn more about contributing:
https://reactjs.org/docs/how-to-contribute.html
-->

## Summary

<!--
Explain the **motivation** for making this change. What existing problem
does the pull request solve?
-->

- Adds a new react-flight-server-fb package providing RSC Flight
bindings for Meta's internal bundler stack
- Unlike webpack/turbopack integrations, this uses no manifest. Module
metadata is self-contained in ClientReference objects and sent over the
wire as-is
- Registers dom-browser-fb and dom-node-fb host configs for Rollup
builds targeting FB_WWW_DEV and FB_WWW_PROD

 Key design differences from other bundler

 - No build-time manifest
- Module IDs use Haste module names (e.g. `"MyComponent"`), with named
exports encoded as `"Module#export"`, rather than file paths resolved
through a manifest
- Client-side loading uses `Bootloader.handlePayload()` +
`JSResource().load()`
- `resolveClientReferenceMetadata` and `resolveClientReference` are
pass-throughs

## How did you test this change?

<!--
Demonstrate the code is solid. Example: The exact commands you ran and
their output, screenshots / videos if the pull request changes the user
interface.
How exactly did you verify that your PR solves the issue you wanted to
solve?
  If you leave this empty, your PR will very likely be closed.
-->

E2E integration test is set up on Meta's internal system.
2026-04-27 09:48:37 -07:00

380 lines
10 KiB
JavaScript

/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
import type {
Request,
ReactClientValue,
} from 'react-server/src/ReactFlightServer';
import type {Destination} from 'react-server/src/ReactServerStreamConfigNode';
import type {Busboy} from 'busboy';
import type {Writable} from 'stream';
import type {Thenable} from 'shared/ReactTypes';
import type {Duplex} from 'stream';
import {Readable} from 'stream';
import {
createRequest,
startWork,
startFlowing,
startFlowingDebug,
stopFlowing,
abort,
resolveDebugMessage,
closeDebugChannel,
} from 'react-server/src/ReactFlightServer';
import {
createResponse,
reportGlobalError,
close,
resolveField,
resolveFileInfo,
resolveFileChunk,
resolveFileComplete,
getRoot,
} from 'react-server/src/ReactFlightReplyServer';
import {
decodeAction,
decodeFormState,
} from 'react-server/src/ReactFlightActionServer';
export {
registerServerReference,
registerClientReference,
} from '../ReactFlightFBReferences';
// Buffer-based string decoder helpers. The FB server environment does not
// have TextDecoder, so we use Buffer.toString('utf8') instead.
type BufferDecoder = {_pendingBytes: Array<Uint8Array>};
function createStringDecoder(): BufferDecoder {
return {_pendingBytes: []};
}
function readPartialStringChunk(
decoder: BufferDecoder,
buffer: Uint8Array,
): string {
return Buffer.from(
buffer.buffer,
buffer.byteOffset,
buffer.byteLength,
).toString('utf8');
}
function readFinalStringChunk(
decoder: BufferDecoder,
buffer: Uint8Array,
): string {
return Buffer.from(
buffer.buffer,
buffer.byteOffset,
buffer.byteLength,
).toString('utf8');
}
import type {TemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences';
export {createTemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences';
export type {TemporaryReferenceSet};
function createDrainHandler(destination: Destination, request: Request) {
return () => startFlowing(request, destination);
}
function createCancelHandler(request: Request, reason: string) {
return () => {
stopFlowing(request);
abort(request, new Error(reason));
};
}
function startReadingFromDebugChannelReadable(
request: Request,
stream: Readable | WebSocket,
): void {
const stringDecoder = createStringDecoder();
let lastWasPartial = false;
let stringBuffer = '';
function onData(chunk: string | Uint8Array) {
if (typeof chunk === 'string') {
if (lastWasPartial) {
stringBuffer += readFinalStringChunk(stringDecoder, new Uint8Array(0));
lastWasPartial = false;
}
stringBuffer += chunk;
} else {
const buffer: Uint8Array = (chunk: any);
stringBuffer += readPartialStringChunk(stringDecoder, buffer);
lastWasPartial = true;
}
const messages = stringBuffer.split('\n');
for (let i = 0; i < messages.length - 1; i++) {
resolveDebugMessage(request, messages[i]);
}
stringBuffer = messages[messages.length - 1];
}
function onError(error: mixed) {
abort(
request,
new Error('Lost connection to the Debug Channel.', {
cause: error,
}),
);
}
function onClose() {
closeDebugChannel(request);
}
if (
// $FlowFixMe[method-unbinding]
typeof stream.addEventListener === 'function' &&
// $FlowFixMe[method-unbinding]
typeof stream.binaryType === 'string'
) {
const ws: WebSocket = (stream: any);
ws.binaryType = 'arraybuffer';
ws.addEventListener('message', event => {
// $FlowFixMe
onData(event.data);
});
ws.addEventListener('error', event => {
// $FlowFixMe
onError(event.error);
});
ws.addEventListener('close', onClose);
} else {
const readable: Readable = (stream: any);
readable.on('data', onData);
readable.on('error', onError);
readable.on('end', onClose);
}
}
type Options = {
debugChannel?: Readable | Writable | Duplex | WebSocket,
environmentName?: string | (() => string),
filterStackFrame?: (url: string, functionName: string) => boolean,
onError?: (error: mixed) => void,
identifierPrefix?: string,
temporaryReferences?: TemporaryReferenceSet,
startTime?: number,
};
type PipeableStream = {
abort(reason: mixed): void,
pipe<T: Writable>(destination: T): T,
};
function renderToPipeableStream(
model: ReactClientValue,
options?: Options,
): PipeableStream {
const debugChannel = __DEV__ && options ? options.debugChannel : undefined;
const debugChannelReadable: void | Readable | WebSocket =
__DEV__ &&
debugChannel !== undefined &&
// $FlowFixMe[method-unbinding]
(typeof debugChannel.read === 'function' ||
typeof debugChannel.readyState === 'number')
? (debugChannel: any)
: undefined;
const debugChannelWritable: void | Writable =
__DEV__ && debugChannel !== undefined
? // $FlowFixMe[method-unbinding]
typeof debugChannel.write === 'function'
? (debugChannel: any)
: // $FlowFixMe[method-unbinding]
typeof debugChannel.send === 'function'
? createFakeWritableFromWebSocket((debugChannel: any))
: undefined
: undefined;
const request = createRequest(
model,
null,
options ? options.onError : undefined,
options ? options.identifierPrefix : undefined,
options ? options.temporaryReferences : undefined,
options ? options.startTime : undefined,
__DEV__ && options ? options.environmentName : undefined,
__DEV__ && options ? options.filterStackFrame : undefined,
debugChannelReadable !== undefined,
);
let hasStartedFlowing = false;
startWork(request);
if (debugChannelWritable !== undefined) {
startFlowingDebug(request, debugChannelWritable);
}
if (debugChannelReadable !== undefined) {
startReadingFromDebugChannelReadable(request, debugChannelReadable);
}
return {
pipe<T: Writable>(destination: T): T {
if (hasStartedFlowing) {
throw new Error(
'React currently only supports piping to one writable stream.',
);
}
hasStartedFlowing = true;
startFlowing(request, destination);
destination.on('drain', createDrainHandler(destination, request));
destination.on(
'error',
createCancelHandler(
request,
'The destination stream errored while writing data.',
),
);
// We don't close until the debug channel closes.
if (!__DEV__ || debugChannelReadable === undefined) {
destination.on(
'close',
createCancelHandler(request, 'The destination stream closed early.'),
);
}
return destination;
},
abort(reason: mixed) {
abort(request, reason);
},
};
}
function createFakeWritableFromWebSocket(webSocket: WebSocket): Writable {
return ({
write(chunk: string | Uint8Array) {
webSocket.send((chunk: any));
return true;
},
end() {
webSocket.close();
},
destroy(reason) {
if (typeof reason === 'object' && reason !== null) {
reason = reason.message;
}
if (typeof reason === 'string') {
webSocket.close(1011, reason);
} else {
webSocket.close(1011);
}
},
}: any);
}
function decodeReplyFromBusboy<T>(
busboyStream: Busboy,
options?: {
temporaryReferences?: TemporaryReferenceSet,
arraySizeLimit?: number,
},
): Thenable<T> {
const response = createResponse(
null,
'',
options ? options.temporaryReferences : undefined,
undefined,
options ? options.arraySizeLimit : undefined,
);
let pendingFiles = 0;
const queuedFields: Array<string> = [];
busboyStream.on('field', (name, value) => {
if (pendingFiles > 0) {
// Because the 'end' event fires two microtasks after the next 'field'
// we would resolve files and fields out of order. To handle this properly
// we queue any fields we receive until the previous file is done.
queuedFields.push(name, value);
} else {
try {
resolveField(response, name, value);
} catch (error) {
busboyStream.destroy(error);
}
}
});
busboyStream.on('file', (name, value, {filename, encoding, mimeType}) => {
if (encoding.toLowerCase() === 'base64') {
busboyStream.destroy(
new Error(
"React doesn't accept base64 encoded file uploads because we don't expect " +
"form data passed from a browser to ever encode data that way. If that's " +
'the wrong assumption, we can easily fix it.',
),
);
return;
}
pendingFiles++;
const file = resolveFileInfo(response, name, filename, mimeType);
value.on('data', chunk => {
resolveFileChunk(response, file, chunk);
});
value.on('end', () => {
try {
resolveFileComplete(response, name, file);
pendingFiles--;
if (pendingFiles === 0) {
// Release any queued fields
for (let i = 0; i < queuedFields.length; i += 2) {
resolveField(response, queuedFields[i], queuedFields[i + 1]);
}
queuedFields.length = 0;
}
} catch (error) {
busboyStream.destroy(error);
}
});
});
busboyStream.on('finish', () => {
close(response);
});
busboyStream.on('error', err => {
reportGlobalError(
response,
// $FlowFixMe[incompatible-call] types Error and mixed are incompatible
err,
);
});
return getRoot(response);
}
function decodeReply<T>(
body: string | FormData,
options?: {
temporaryReferences?: TemporaryReferenceSet,
arraySizeLimit?: number,
},
): Thenable<T> {
if (typeof body === 'string') {
const form = new FormData();
form.append('0', body);
body = form;
}
const response = createResponse(
null,
'',
options ? options.temporaryReferences : undefined,
body,
options ? options.arraySizeLimit : undefined,
);
const root = getRoot<T>(response);
close(response);
return root;
}
export {
renderToPipeableStream,
decodeReply,
decodeReplyFromBusboy,
decodeAction,
decodeFormState,
};