diff --git a/.eslintrc.js b/.eslintrc.js index 128d872d4647..a922421e83fb 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -331,6 +331,7 @@ module.exports = { 'packages/react-server-dom-turbopack/**/*.js', 'packages/react-server-dom-parcel/**/*.js', 'packages/react-server-dom-fb/**/*.js', + 'packages/react-flight-server-fb/**/*.js', 'packages/react-server-dom-unbundled/**/*.js', 'packages/react-test-renderer/**/*.js', 'packages/react-debug-tools/**/*.js', diff --git a/.github/workflows/runtime_build_and_test.yml b/.github/workflows/runtime_build_and_test.yml index 3eec5f90bee3..09630c90a918 100644 --- a/.github/workflows/runtime_build_and_test.yml +++ b/.github/workflows/runtime_build_and_test.yml @@ -45,7 +45,7 @@ jobs: with: path: | **/node_modules - key: runtime-node_modules-v7-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock') }} + key: runtime-node_modules-v8-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock') }} lookup-only: true - uses: actions/setup-node@v4 if: steps.node_modules.outputs.cache-hit != 'true' @@ -59,7 +59,7 @@ jobs: with: path: | **/node_modules - key: runtime-node_modules-v7-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock') }} + key: runtime-node_modules-v8-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock') }} # Don't use restore-keys here. Otherwise the cache grows indefinitely. - run: yarn install --frozen-lockfile if: steps.node_modules.outputs.cache-hit != 'true' @@ -69,7 +69,7 @@ jobs: with: path: | **/node_modules - key: runtime-node_modules-v7-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock') }} + key: runtime-node_modules-v8-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock') }} runtime_compiler_node_modules_cache: name: Cache Runtime, Compiler node_modules @@ -84,7 +84,7 @@ jobs: with: path: | **/node_modules - key: runtime-and-compiler-node_modules-v7-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock', 'compiler/yarn.lock') }} + key: runtime-and-compiler-node_modules-v8-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock', 'compiler/yarn.lock') }} lookup-only: true - uses: actions/setup-node@v4 if: steps.node_modules.outputs.cache-hit != 'true' @@ -100,7 +100,7 @@ jobs: with: path: | **/node_modules - key: runtime-and-compiler-node_modules-v7-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock', 'compiler/yarn.lock') }} + key: runtime-and-compiler-node_modules-v8-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock', 'compiler/yarn.lock') }} # Don't use restore-keys here. Otherwise the cache grows indefinitely. - run: yarn install --frozen-lockfile if: steps.node_modules.outputs.cache-hit != 'true' @@ -112,7 +112,7 @@ jobs: with: path: | **/node_modules - key: runtime-and-compiler-node_modules-v7-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock', 'compiler/yarn.lock') }} + key: runtime-and-compiler-node_modules-v8-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock', 'compiler/yarn.lock') }} # ----- FLOW ----- discover_flow_inline_configs: @@ -154,7 +154,7 @@ jobs: with: path: | **/node_modules - key: runtime-node_modules-v7-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock') }} + key: runtime-node_modules-v8-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock') }} # Don't use restore-keys here. Otherwise the cache grows indefinitely. - name: Ensure clean build directory run: rm -rf build @@ -182,7 +182,7 @@ jobs: with: path: | **/node_modules - key: runtime-node_modules-v7-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock') }} + key: runtime-node_modules-v8-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock') }} # Don't use restore-keys here. Otherwise the cache grows indefinitely. - name: Ensure clean build directory run: rm -rf build @@ -212,7 +212,7 @@ jobs: with: path: | **/node_modules - key: runtime-node_modules-v7-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock') }} + key: runtime-node_modules-v8-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock') }} - name: Ensure clean build directory run: rm -rf build - run: yarn install --frozen-lockfile @@ -270,7 +270,7 @@ jobs: with: path: | **/node_modules - key: runtime-and-compiler-node_modules-v7-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock', 'compiler/yarn.lock') }} + key: runtime-and-compiler-node_modules-v8-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock', 'compiler/yarn.lock') }} # Don't use restore-keys here. Otherwise the cache grows indefinitely. - name: Ensure clean build directory run: rm -rf build @@ -301,7 +301,7 @@ jobs: with: path: | **/node_modules - key: runtime-and-compiler-node_modules-v7-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock', 'compiler/yarn.lock') }} + key: runtime-and-compiler-node_modules-v8-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock', 'compiler/yarn.lock') }} - name: Install runtime dependencies run: yarn install --frozen-lockfile if: steps.node_modules.outputs.cache-hit != 'true' @@ -344,7 +344,7 @@ jobs: with: path: | **/node_modules - key: runtime-and-compiler-node_modules-v7-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock', 'compiler/yarn.lock') }} + key: runtime-and-compiler-node_modules-v8-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock', 'compiler/yarn.lock') }} # Don't use restore-keys here. Otherwise the cache grows indefinitely. - name: Ensure clean build directory run: rm -rf build @@ -430,7 +430,7 @@ jobs: with: path: | **/node_modules - key: runtime-and-compiler-node_modules-v7-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock', 'compiler/yarn.lock') }} + key: runtime-and-compiler-node_modules-v8-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock', 'compiler/yarn.lock') }} # Don't use restore-keys here. Otherwise the cache grows indefinitely. - name: Ensure clean build directory run: rm -rf build @@ -477,7 +477,7 @@ jobs: with: path: | **/node_modules - key: runtime-node_modules-v7-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock') }} + key: runtime-node_modules-v8-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock') }} # Don't use restore-keys here. Otherwise the cache grows indefinitely. - name: Ensure clean build directory run: rm -rf build @@ -517,7 +517,7 @@ jobs: with: path: | **/node_modules - key: runtime-node_modules-v7-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock') }} + key: runtime-node_modules-v8-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock') }} # Don't use restore-keys here. Otherwise the cache grows indefinitely. - name: Ensure clean build directory run: rm -rf build @@ -580,7 +580,7 @@ jobs: with: path: | **/node_modules - key: runtime-node_modules-v7-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock') }} + key: runtime-node_modules-v8-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock') }} # Don't use restore-keys here. Otherwise the cache grows indefinitely. - name: Ensure clean build directory run: rm -rf build @@ -618,7 +618,7 @@ jobs: with: path: | **/node_modules - key: runtime-node_modules-v7-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock') }} + key: runtime-node_modules-v8-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock') }} # Don't use restore-keys here. Otherwise the cache grows indefinitely. - name: Ensure clean build directory run: rm -rf build @@ -768,7 +768,7 @@ jobs: with: path: | **/node_modules - key: runtime-node_modules-v7-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock') }} + key: runtime-node_modules-v8-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock') }} # Don't use restore-keys here. Otherwise the cache grows indefinitely. - name: Ensure clean build directory run: rm -rf build @@ -828,7 +828,7 @@ jobs: with: path: | **/node_modules - key: runtime-node_modules-v7-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock') }} + key: runtime-node_modules-v8-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock') }} # Don't use restore-keys here. Otherwise the cache grows indefinitely. - name: Ensure clean build directory run: rm -rf build diff --git a/.github/workflows/shared_lint.yml b/.github/workflows/shared_lint.yml index 3c359cff2280..c778c7b2abe8 100644 --- a/.github/workflows/shared_lint.yml +++ b/.github/workflows/shared_lint.yml @@ -33,7 +33,7 @@ jobs: with: path: | **/node_modules - key: shared-lint-node_modules-v6-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} + key: shared-lint-node_modules-v7-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} - name: Ensure clean build directory run: rm -rf build - run: yarn install --frozen-lockfile @@ -56,7 +56,7 @@ jobs: with: path: | **/node_modules - key: shared-lint-node_modules-v6-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} + key: shared-lint-node_modules-v7-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} - name: Ensure clean build directory run: rm -rf build - run: yarn install --frozen-lockfile @@ -79,7 +79,7 @@ jobs: with: path: | **/node_modules - key: shared-lint-node_modules-v6-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} + key: shared-lint-node_modules-v7-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} - name: Ensure clean build directory run: rm -rf build - run: yarn install --frozen-lockfile @@ -102,7 +102,7 @@ jobs: with: path: | **/node_modules - key: shared-lint-node_modules-v6-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} + key: shared-lint-node_modules-v7-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} - name: Ensure clean build directory run: rm -rf build - run: yarn install --frozen-lockfile diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-browser-fb.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-browser-fb.js new file mode 100644 index 000000000000..d67fbdba889f --- /dev/null +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-browser-fb.js @@ -0,0 +1,19 @@ +/** + * 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 + */ + +export {default as rendererVersion} from 'shared/ReactVersion'; +export const rendererPackageName = 'react-flight-server-fb'; + +export * from 'react-client/src/ReactFlightClientStreamConfigWeb'; +export * from 'react-client/src/ReactClientConsoleConfigBrowser'; +export * from 'react-client/src/ReactClientDebugConfigBrowser'; +export * from 'react-flight-server-fb/src/client/ReactFlightClientConfigBundlerFB'; +export * from 'react-flight-server-fb/src/client/ReactFlightClientConfigTargetFBBrowser'; +export * from 'react-flight-server-fb/src/client/ReactFlightClientConfigDOMFB'; +export const usedWithSSR = false; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-fb.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-fb.js new file mode 100644 index 000000000000..8636ef9d8399 --- /dev/null +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-fb.js @@ -0,0 +1,19 @@ +/** + * 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 + */ + +export {default as rendererVersion} from 'shared/ReactVersion'; +export const rendererPackageName = 'react-flight-server-fb'; + +export * from 'react-client/src/ReactFlightClientStreamConfigWeb'; +export * from 'react-client/src/ReactClientConsoleConfigServer'; +export * from 'react-client/src/ReactClientDebugConfigNode'; +export * from 'react-flight-server-fb/src/client/ReactFlightClientConfigBundlerFB'; +export * from 'react-flight-server-fb/src/client/ReactFlightClientConfigTargetFBBrowser'; +export * from 'react-flight-server-fb/src/client/ReactFlightClientConfigDOMFB'; +export const usedWithSSR = false; diff --git a/packages/react-flight-server-fb/README.md b/packages/react-flight-server-fb/README.md new file mode 100644 index 000000000000..ae0f43b90a8e --- /dev/null +++ b/packages/react-flight-server-fb/README.md @@ -0,0 +1,5 @@ +# react-flight-server-fb + +React Flight bindings for DOM using Meta's internal bundler. + +**Use it at your own risk.** diff --git a/packages/react-flight-server-fb/client.browser.js b/packages/react-flight-server-fb/client.browser.js new file mode 100644 index 000000000000..945ceed7f394 --- /dev/null +++ b/packages/react-flight-server-fb/client.browser.js @@ -0,0 +1,10 @@ +/** + * 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 + */ + +export * from './src/client/ReactFlightDOMClientBrowser'; diff --git a/packages/react-flight-server-fb/package.json b/packages/react-flight-server-fb/package.json new file mode 100644 index 000000000000..b802a6abaf85 --- /dev/null +++ b/packages/react-flight-server-fb/package.json @@ -0,0 +1,34 @@ +{ + "name": "react-flight-server-fb", + "description": "React Server Components bindings for DOM using Meta's internal bundler. It is not intended to be imported directly.", + "version": "19.3.0", + "keywords": [ + "react" + ], + "homepage": "https://react.dev/", + "bugs": "https://github.com/facebook/react/issues", + "license": "MIT", + "files": [ + "LICENSE", + "README.md", + "client.browser.js" + ], + "exports": { + "./client": "./client.browser.js", + "./client.browser": "./client.browser.js", + "./src/*": "./src/*.js", + "./package.json": "./package.json" + }, + "repository": { + "type" : "git", + "url" : "https://github.com/facebook/react.git", + "directory": "packages/react-flight-server-fb" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + } +} diff --git a/packages/react-flight-server-fb/src/ReactFlightFBReferences.js b/packages/react-flight-server-fb/src/ReactFlightFBReferences.js new file mode 100644 index 000000000000..d639467f6bd3 --- /dev/null +++ b/packages/react-flight-server-fb/src/ReactFlightFBReferences.js @@ -0,0 +1,131 @@ +/** + * 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 {ReactClientValue} from 'react-server/src/ReactFlightServer'; + +export type ServerReference = T & { + $$typeof: symbol, + $$id: string, + $$bound: null | Array, + $$location?: Error, +}; + +// eslint-disable-next-line no-unused-vars +export type ClientReference = { + $$typeof: symbol, + $$id: string, + $$hblp: mixed, +}; + +const CLIENT_REFERENCE_TAG = Symbol.for('react.client.reference'); +const SERVER_REFERENCE_TAG = Symbol.for('react.server.reference'); + +export function isClientReference(reference: Object): boolean { + return reference.$$typeof === CLIENT_REFERENCE_TAG; +} + +export function isServerReference(reference: Object): boolean { + return reference.$$typeof === SERVER_REFERENCE_TAG; +} + +export function registerClientReference( + proxyImplementation: any, + id: string, + hblp: mixed, +): ClientReference { + return Object.defineProperties(proxyImplementation, { + $$typeof: {value: CLIENT_REFERENCE_TAG}, + $$id: {value: id}, + $$hblp: {value: hblp}, + }); +} + +// $FlowFixMe[method-unbinding] +const FunctionBind = Function.prototype.bind; +// $FlowFixMe[method-unbinding] +const ArraySlice = Array.prototype.slice; +function bind(this: ServerReference): any { + // $FlowFixMe[incompatible-call] + const newFn = FunctionBind.apply(this, arguments); + if (this.$$typeof === SERVER_REFERENCE_TAG) { + if (__DEV__) { + const thisBind = arguments[0]; + if (thisBind != null) { + console.error( + 'Cannot bind "this" of a Server Action. Pass null or undefined as the first argument to .bind().', + ); + } + } + const args = ArraySlice.call(arguments, 1); + const $$typeof = {value: SERVER_REFERENCE_TAG}; + const $$id = {value: this.$$id}; + const $$bound = {value: this.$$bound ? this.$$bound.concat(args) : args}; + return Object.defineProperties( + (newFn: any), + (__DEV__ + ? { + $$typeof, + $$id, + $$bound, + $$location: { + value: this.$$location, + configurable: true, + }, + bind: {value: bind, configurable: true}, + } + : { + $$typeof, + $$id, + $$bound, + bind: {value: bind, configurable: true}, + }) as PropertyDescriptorMap, + ); + } + return newFn; +} + +const serverReferenceToString = { + value: () => 'function () { [omitted code] }', + configurable: true, + writable: true, +}; + +export function registerServerReference( + reference: T, + id: string, +): ServerReference { + const $$typeof = {value: SERVER_REFERENCE_TAG}; + const $$id = { + value: id, + configurable: true, + }; + const $$bound = {value: null, configurable: true}; + return Object.defineProperties( + (reference: any), + (__DEV__ + ? { + $$typeof, + $$id, + $$bound, + $$location: { + value: Error('react-stack-top-frame'), + configurable: true, + }, + bind: {value: bind, configurable: true}, + toString: serverReferenceToString, + } + : { + $$typeof, + $$id, + $$bound, + bind: {value: bind, configurable: true}, + toString: serverReferenceToString, + }) as PropertyDescriptorMap, + ); +} diff --git a/packages/react-flight-server-fb/src/ReactServerStreamConfigFB.js b/packages/react-flight-server-fb/src/ReactServerStreamConfigFB.js new file mode 100644 index 000000000000..be6f854f1b02 --- /dev/null +++ b/packages/react-flight-server-fb/src/ReactServerStreamConfigFB.js @@ -0,0 +1,240 @@ +/** + * 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 + */ + +// Fork of ReactServerStreamConfigNode for the FB Flight server. +// Avoids importing from 'util' and 'crypto'. Uses the global TextEncoder +// and stubs createFastHash. + +import type {Writable} from 'stream'; + +import type {TextEncoder as TextEncoderType} from 'util'; + +interface MightBeFlushable { + flush?: () => void; +} + +export type Destination = Writable & MightBeFlushable; + +export type PrecomputedChunk = Uint8Array; +export opaque type Chunk = string; +export type BinaryChunk = Uint8Array; + +export function scheduleWork(callback: () => void) { + setImmediate(callback); +} + +export function scheduleMicrotask(callback: () => void) { + Promise.resolve().then(callback); +} + +export function flushBuffered(destination: Destination) { + // If we don't have any more data to send right now. + // Flush whatever is in the buffer to the wire. + if (typeof destination.flush === 'function') { + // By convention the Zlib streams provide a flush function for this purpose. + // For Express, compression middleware adds this method. + destination.flush(); + } +} + +const VIEW_SIZE = 4096; +let currentView = null; +let writtenBytes = 0; +let destinationHasCapacity = true; + +export function beginWriting(destination: Destination) { + currentView = new Uint8Array(VIEW_SIZE); + writtenBytes = 0; + destinationHasCapacity = true; +} + +function writeStringChunk(destination: Destination, stringChunk: string) { + if (stringChunk.length === 0) { + return; + } + // maximum possible view needed to encode entire string + if (stringChunk.length * 3 > VIEW_SIZE) { + if (writtenBytes > 0) { + writeToDestination( + destination, + ((currentView: any): Uint8Array).subarray(0, writtenBytes), + ); + currentView = new Uint8Array(VIEW_SIZE); + writtenBytes = 0; + } + // Write the raw string chunk and let the consumer handle the encoding. + writeToDestination(destination, stringChunk); + return; + } + + let target: Uint8Array = (currentView: any); + if (writtenBytes > 0) { + target = ((currentView: any): Uint8Array).subarray(writtenBytes); + } + const {read, written} = textEncoder.encodeInto(stringChunk, target); + writtenBytes += written; + + if (read < stringChunk.length) { + writeToDestination( + destination, + (currentView: any).subarray(0, writtenBytes), + ); + currentView = new Uint8Array(VIEW_SIZE); + writtenBytes = textEncoder.encodeInto( + stringChunk.slice(read), + (currentView: any), + ).written; + } + + if (writtenBytes === VIEW_SIZE) { + writeToDestination(destination, (currentView: any)); + currentView = new Uint8Array(VIEW_SIZE); + writtenBytes = 0; + } +} + +function writeViewChunk( + destination: Destination, + chunk: PrecomputedChunk | BinaryChunk, +) { + if (chunk.byteLength === 0) { + return; + } + if (chunk.byteLength > VIEW_SIZE) { + if (writtenBytes > 0) { + writeToDestination( + destination, + ((currentView: any): Uint8Array).subarray(0, writtenBytes), + ); + currentView = new Uint8Array(VIEW_SIZE); + writtenBytes = 0; + } + writeToDestination(destination, chunk); + return; + } + + let bytesToWrite = chunk; + const allowableBytes = ((currentView: any): Uint8Array).length - writtenBytes; + if (allowableBytes < bytesToWrite.byteLength) { + if (allowableBytes === 0) { + writeToDestination(destination, (currentView: any)); + } else { + ((currentView: any): Uint8Array).set( + bytesToWrite.subarray(0, allowableBytes), + writtenBytes, + ); + writtenBytes += allowableBytes; + writeToDestination(destination, (currentView: any)); + bytesToWrite = bytesToWrite.subarray(allowableBytes); + } + currentView = new Uint8Array(VIEW_SIZE); + writtenBytes = 0; + } + ((currentView: any): Uint8Array).set(bytesToWrite, writtenBytes); + writtenBytes += bytesToWrite.byteLength; + + if (writtenBytes === VIEW_SIZE) { + writeToDestination(destination, (currentView: any)); + currentView = new Uint8Array(VIEW_SIZE); + writtenBytes = 0; + } +} + +export function writeChunk( + destination: Destination, + chunk: PrecomputedChunk | Chunk | BinaryChunk, +): void { + if (typeof chunk === 'string') { + writeStringChunk(destination, chunk); + } else { + writeViewChunk(destination, ((chunk: any): PrecomputedChunk | BinaryChunk)); + } +} + +function writeToDestination( + destination: Destination, + view: string | Uint8Array, +) { + const currentHasCapacity = destination.write(view); + destinationHasCapacity = destinationHasCapacity && currentHasCapacity; +} + +export function writeChunkAndReturn( + destination: Destination, + chunk: PrecomputedChunk | Chunk, +): boolean { + writeChunk(destination, chunk); + return destinationHasCapacity; +} + +export function completeWriting(destination: Destination) { + if (currentView && writtenBytes > 0) { + destination.write(currentView.subarray(0, writtenBytes)); + } + currentView = null; + writtenBytes = 0; + destinationHasCapacity = true; +} + +export function close(destination: Destination) { + destination.end(); +} + +export const textEncoder: TextEncoderType = (new TextEncoder(): any); + +export function stringToChunk(content: string): Chunk { + return content; +} + +export function stringToPrecomputedChunk(content: string): PrecomputedChunk { + const precomputedChunk = textEncoder.encode(content); + + if (__DEV__) { + if (precomputedChunk.byteLength > VIEW_SIZE) { + console.error( + 'precomputed chunks must be smaller than the view size configured for this host. This is a bug in React.', + ); + } + } + + return precomputedChunk; +} + +export function typedArrayToBinaryChunk( + content: $ArrayBufferView, +): BinaryChunk { + return new Uint8Array(content.buffer, content.byteOffset, content.byteLength); +} + +export function byteLengthOfChunk(chunk: Chunk | PrecomputedChunk): number { + return typeof chunk === 'string' + ? Buffer.byteLength(chunk, 'utf8') + : chunk.byteLength; +} + +export function byteLengthOfBinaryChunk(chunk: BinaryChunk): number { + return chunk.byteLength; +} + +export function closeWithError(destination: Destination, error: mixed): void { + // $FlowFixMe[incompatible-call]: This is an Error object or the destination accepts other types. + destination.destroy(error); +} + +export function createFastHash(_input: string): string | number { + throw new Error('createFastHash is not supported in this environment.'); +} + +export function readAsDataURL(blob: Blob): Promise { + return blob.arrayBuffer().then(arrayBuffer => { + const encoded = Buffer.from(arrayBuffer).toString('base64'); + const mimeType = blob.type || 'application/octet-stream'; + return 'data:' + mimeType + ';base64,' + encoded; + }); +} diff --git a/packages/react-flight-server-fb/src/client/ReactFlightClientConfigBundlerFB.js b/packages/react-flight-server-fb/src/client/ReactFlightClientConfigBundlerFB.js new file mode 100644 index 000000000000..79213ec360ac --- /dev/null +++ b/packages/react-flight-server-fb/src/client/ReactFlightClientConfigBundlerFB.js @@ -0,0 +1,239 @@ +/** + * 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 { + Thenable, + FulfilledThenable, + RejectedThenable, + ReactDebugInfo, + ReactIOInfo, + ReactAsyncInfo, +} from 'shared/ReactTypes'; + +import type {ModuleLoading} from 'react-client/src/ReactFlightClientConfig'; + +import {canUseDOM} from 'shared/ExecutionEnvironment'; + +export type ServerConsumerModuleMap = null; + +export type ServerManifest = null; + +export type ServerReferenceId = string; + +import {prepareDestinationForModuleImpl} from 'react-client/src/ReactFlightClientConfig'; + +export opaque type ClientReferenceMetadata = { + $$typeof: symbol, + $$id: string, + $$hblp: mixed, +}; + +// eslint-disable-next-line no-unused-vars +export opaque type ClientReference = { + $$typeof: symbol, + $$id: string, + $$hblp: mixed, +}; + +export function prepareDestinationForModule( + moduleLoading: ModuleLoading, + nonce: ?string, + metadata: ClientReferenceMetadata, +) { + prepareDestinationForModuleImpl(moduleLoading, metadata.$$hblp, nonce); +} + +export function resolveClientReference( + bundlerConfig: ServerConsumerModuleMap, + metadata: ClientReferenceMetadata, +): ClientReference { + return metadata; +} + +// Called on the server when decoding a client's action reply (via decodeReply). +// Maps the $$id string sent by the client back to module metadata so the server +// can locate and execute the actual function. $$hblp is null because this runs +// on the server — there is no client module to download. +export function resolveServerReference( + config: ServerManifest, + id: ServerReferenceId, +): ClientReference { + return ({ + $$typeof: Symbol.for('react.client.reference'), + $$id: id, + $$hblp: null, + }: any); +} + +const asyncModuleCache: Map> = new Map(); + +export function preloadModule( + metadata: ClientReference, +): null | Thenable { + if (!canUseDOM) { + // Server environment: modules are synchronously available via require(). + return null; + } + + // $FlowFixMe[cannot-resolve-module] JSResource is a Meta-internal module + const jsr: any = require('JSResource')(metadata.$$id); + + const previouslyLoadedModule = jsr.getModuleIfRequireable(); + if (previouslyLoadedModule != null) { + return null; + } + + if (metadata.$$hblp != null) { + // Register our updates with the bootloader. + window.Bootloader.handlePayload(metadata.$$hblp); + } + + const modulePromise: Thenable = jsr.load(); + modulePromise.then( + value => { + const fulfilledThenable: FulfilledThenable = (modulePromise: any); + fulfilledThenable.status = 'fulfilled'; + fulfilledThenable.value = value; + }, + reason => { + const rejectedThenable: RejectedThenable = (modulePromise: any); + rejectedThenable.status = 'rejected'; + rejectedThenable.reason = reason; + }, + ); + asyncModuleCache.set(metadata.$$id, modulePromise); + return modulePromise; +} + +export function requireModule(metadata: ClientReference): T { + if (!canUseDOM) { + // When the Flight client runs on the server to consume a Flight stream, + // modules are resolved synchronously via require() with Haste module names. + const id = metadata.$$id; + const idx = id.lastIndexOf('#'); + if (idx !== -1) { + const moduleName = id.slice(0, idx); + const exportName = id.slice(idx + 1); + // Use .call to prevent bundlers from statically resolving this require. + const mod = require.call(null, moduleName); // eslint-disable-line no-useless-call + if (exportName === '' || exportName === 'default') { + return mod.__esModule ? mod.default : mod; + } + return mod[exportName]; + } + // Use .call to prevent bundlers from statically resolving this require. + return require.call(null, id); // eslint-disable-line no-useless-call + } + + // $FlowFixMe[cannot-resolve-module] JSResource is a Meta-internal module + const jsr: any = require('JSResource')(metadata.$$id); + + const moduleExports = jsr.getModuleIfRequireable(); + if (moduleExports != null) { + return moduleExports; + } + + // Fall back to the async cache if JSResource doesn't have it yet. + const promise: any = asyncModuleCache.get(metadata.$$id); + if (promise && promise.status === 'fulfilled') { + return promise.value; + } else { + throw promise.reason; + } +} + +// We cache ReactIOInfo across requests so that inner refreshes can dedupe with outer. +const moduleIOInfoCache: Map = __DEV__ + ? new Map() + : (null: any); + +export function getModuleDebugInfo( + metadata: ClientReference, +): null | ReactDebugInfo { + if (!__DEV__) { + return null; + } + const filename = metadata.$$id; + let ioInfo = moduleIOInfoCache.get(filename); + if (ioInfo === undefined) { + let href; + try { + // $FlowFixMe + href = new URL(filename, document.baseURI).href; + } catch (_) { + href = filename; + } + let start = -1; + let end = -1; + let byteSize = 0; + // $FlowFixMe[method-unbinding] + if (typeof performance.getEntriesByType === 'function') { + // We may be able to collect the start and end time of this resource from Performance Observer. + const resourceEntries = performance.getEntriesByType('resource'); + for (let i = 0; i < resourceEntries.length; i++) { + const resourceEntry = resourceEntries[i]; + if (resourceEntry.name === href) { + start = resourceEntry.startTime; + end = start + resourceEntry.duration; + // $FlowFixMe[prop-missing] + byteSize = (resourceEntry.transferSize: any) || 0; + } + } + } + const value = Promise.resolve(href); + // $FlowFixMe + value.status = 'fulfilled'; + // Is there some more useful representation for the chunk? + // $FlowFixMe + value.value = href; + // Create a fake stack frame that points to the beginning of the chunk. This is + // probably not source mapped so will link to the compiled source rather than + // any individual file that goes into the chunks. + const fakeStack = new Error('react-stack-top-frame'); + if (fakeStack.stack.startsWith('Error: react-stack-top-frame')) { + // Looks like V8 + fakeStack.stack = + 'Error: react-stack-top-frame\n' + + // Add two frames since we always trim one off the top. + ' at Client Component Bundle (' + + href + + ':1:1)\n' + + ' at Client Component Bundle (' + + href + + ':1:1)'; + } else { + // Looks like Firefox or Safari. + // Add two frames since we always trim one off the top. + fakeStack.stack = + 'Client Component Bundle@' + + href + + ':1:1\n' + + 'Client Component Bundle@' + + href + + ':1:1'; + } + ioInfo = ({ + name: 'script', + start: start, + end: end, + value: value, + debugStack: fakeStack, + }: ReactIOInfo); + if (byteSize > 0) { + // $FlowFixMe[cannot-write] + ioInfo.byteSize = byteSize; + } + moduleIOInfoCache.set(filename, ioInfo); + } + // We could dedupe the async info too but conceptually each request is its own await. + const asyncInfo: ReactAsyncInfo = { + awaited: ioInfo, + }; + return [asyncInfo]; +} diff --git a/packages/react-flight-server-fb/src/client/ReactFlightClientConfigDOMFB.js b/packages/react-flight-server-fb/src/client/ReactFlightClientConfigDOMFB.js new file mode 100644 index 000000000000..8469fd383847 --- /dev/null +++ b/packages/react-flight-server-fb/src/client/ReactFlightClientConfigDOMFB.js @@ -0,0 +1,34 @@ +/** + * 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 + */ + +// Custom implementation of FlightClientConfigDOM. +// Resource hint dispatching is a no-op since we handle resource loading +// through our own runtime. + +import type { + HintCode, + HintModel, +} from 'react-dom-bindings/src/server/ReactFlightServerConfigDOM'; + +export function dispatchHint( + code: Code, + model: HintModel, +): void {} + +export function preinitModuleForSSR( + href: string, + nonce: ?string, + crossOrigin: ?string, +) {} + +export function preinitScriptForSSR( + href: string, + nonce: ?string, + crossOrigin: ?string, +) {} diff --git a/packages/react-flight-server-fb/src/client/ReactFlightClientConfigTargetFBBrowser.js b/packages/react-flight-server-fb/src/client/ReactFlightClientConfigTargetFBBrowser.js new file mode 100644 index 000000000000..0ba181c4cdfe --- /dev/null +++ b/packages/react-flight-server-fb/src/client/ReactFlightClientConfigTargetFBBrowser.js @@ -0,0 +1,18 @@ +/** + * 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 + */ + +export type ModuleLoading = null; + +export function prepareDestinationForModuleImpl( + moduleLoading: ModuleLoading, + chunks: mixed, + nonce: ?string, +) { + // In the browser we don't need to prepare our destination since the browser is the Destination +} diff --git a/packages/react-flight-server-fb/src/client/ReactFlightDOMClientBrowser.js b/packages/react-flight-server-fb/src/client/ReactFlightDOMClientBrowser.js new file mode 100644 index 000000000000..4e8889261b8a --- /dev/null +++ b/packages/react-flight-server-fb/src/client/ReactFlightDOMClientBrowser.js @@ -0,0 +1,295 @@ +/** + * 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 {Thenable} from 'shared/ReactTypes.js'; + +import type { + DebugChannel, + DebugChannelCallback, + FindSourceMapURLCallback, + Response as FlightResponse, +} from 'react-client/src/ReactFlightClient'; + +import type {ReactServerValue} from 'react-client/src/ReactFlightReplyClient'; + +import { + createResponse, + createStreamState, + getRoot, + reportGlobalError, + processBinaryChunk, + processStringChunk, + close, + injectIntoDevTools, +} from 'react-client/src/ReactFlightClient'; + +import {processReply} from 'react-client/src/ReactFlightReplyClient'; + +export { + createServerReference, + registerServerReference, +} from 'react-client/src/ReactFlightReplyClient'; + +import type {TemporaryReferenceSet} from 'react-client/src/ReactFlightTemporaryReferences'; + +export {createTemporaryReferenceSet} from 'react-client/src/ReactFlightTemporaryReferences'; + +export type {TemporaryReferenceSet}; + +type CallServerCallback = (string, args: A) => Promise; + +export type Options = { + callServer?: CallServerCallback, + debugChannel?: {writable?: WritableStream, readable?: ReadableStream, ...}, + temporaryReferences?: TemporaryReferenceSet, + unstable_allowPartialStream?: boolean, + findSourceMapURL?: FindSourceMapURLCallback, + replayConsoleLogs?: boolean, + environmentName?: string, + startTime?: number, + endTime?: number, +}; + +function createDebugCallbackFromWritableStream( + debugWritable: WritableStream, +): DebugChannelCallback { + const textEncoder = new TextEncoder(); + const writer = debugWritable.getWriter(); + return message => { + if (message === '') { + writer.close(); + } else { + // Note: It's important that this function doesn't close over the Response object or it can't be GC:ed. + // Therefore, we can't report errors from this write back to the Response object. + if (__DEV__) { + writer.write(textEncoder.encode(message + '\n')).catch(console.error); + } + } + }; +} + +function createResponseFromOptions(options: void | Options) { + const debugChannel: void | DebugChannel = + __DEV__ && options && options.debugChannel !== undefined + ? { + hasReadable: options.debugChannel.readable !== undefined, + callback: + options.debugChannel.writable !== undefined + ? createDebugCallbackFromWritableStream( + options.debugChannel.writable, + ) + : null, + } + : undefined; + + return createResponse( + null, + null, + null, + options && options.callServer ? options.callServer : undefined, + undefined, // encodeFormAction + undefined, // nonce + options && options.temporaryReferences + ? options.temporaryReferences + : undefined, + options && options.unstable_allowPartialStream + ? options.unstable_allowPartialStream + : false, + __DEV__ && options && options.findSourceMapURL + ? options.findSourceMapURL + : undefined, + __DEV__ ? (options ? options.replayConsoleLogs !== false : true) : false, // defaults to true + __DEV__ && options && options.environmentName + ? options.environmentName + : undefined, + __DEV__ && options && options.startTime != null + ? options.startTime + : undefined, + __DEV__ && options && options.endTime != null ? options.endTime : undefined, + debugChannel, + ); +} + +function startReadingFromUniversalStream( + response: FlightResponse, + stream: ReadableStream, + onDone: () => void, +): void { + // This is the same as startReadingFromStream except this allows WebSocketStreams which + // return ArrayBuffer and string chunks instead of Uint8Array chunks. We could potentially + // always allow streams with variable chunk types. + const streamState = createStreamState(response, stream); + const reader = stream.getReader(); + function progress({ + done, + value, + }: { + done: boolean, + value: any, + ... + }): void | Promise { + if (done) { + return onDone(); + } + if (value instanceof ArrayBuffer) { + // WebSockets can produce ArrayBuffer values in ReadableStreams. + processBinaryChunk(response, streamState, new Uint8Array(value)); + } else if (typeof value === 'string') { + // WebSockets can produce string values in ReadableStreams. + processStringChunk(response, streamState, value); + } else { + processBinaryChunk(response, streamState, value); + } + return reader.read().then(progress).catch(error); + } + function error(e: any) { + reportGlobalError(response, e); + } + reader.read().then(progress).catch(error); +} + +function startReadingFromStream( + response: FlightResponse, + stream: ReadableStream, + onDone: () => void, + debugValue: mixed, +): void { + const streamState = createStreamState(response, debugValue); + const reader = stream.getReader(); + function progress({ + done, + value, + }: { + done: boolean, + value: ?any, + ... + }): void | Promise { + if (done) { + return onDone(); + } + const buffer: Uint8Array = (value: any); + processBinaryChunk(response, streamState, buffer); + return reader.read().then(progress).catch(error); + } + function error(e: any) { + reportGlobalError(response, e); + } + reader.read().then(progress).catch(error); +} +function createFromReadableStream( + stream: ReadableStream, + options?: Options, +): Thenable { + const response: FlightResponse = createResponseFromOptions(options); + if ( + __DEV__ && + options && + options.debugChannel && + options.debugChannel.readable + ) { + let streamDoneCount = 0; + const handleDone = () => { + if (++streamDoneCount === 2) { + close(response); + } + }; + startReadingFromUniversalStream( + response, + options.debugChannel.readable, + handleDone, + ); + startReadingFromStream(response, stream, handleDone, stream); + } else { + startReadingFromStream( + response, + stream, + close.bind(null, response), + stream, + ); + } + return getRoot(response); +} + +function createFromFetch( + promiseForResponse: Promise, + options?: Options, +): Thenable { + const response: FlightResponse = createResponseFromOptions(options); + promiseForResponse.then( + function (r) { + if ( + __DEV__ && + options && + options.debugChannel && + options.debugChannel.readable + ) { + let streamDoneCount = 0; + const handleDone = () => { + if (++streamDoneCount === 2) { + close(response); + } + }; + startReadingFromUniversalStream( + response, + options.debugChannel.readable, + handleDone, + ); + startReadingFromStream(response, (r.body: any), handleDone, r); + } else { + startReadingFromStream( + response, + (r.body: any), + close.bind(null, response), + r, + ); + } + }, + function (e) { + reportGlobalError(response, e); + }, + ); + return getRoot(response); +} + +function encodeReply( + value: ReactServerValue, + options?: {temporaryReferences?: TemporaryReferenceSet, signal?: AbortSignal}, +): Promise< + string | URLSearchParams | FormData, +> /* We don't use URLSearchParams yet but maybe */ { + return new Promise((resolve, reject) => { + const abort = processReply( + value, + '', + options && options.temporaryReferences + ? options.temporaryReferences + : undefined, + resolve, + reject, + ); + if (options && options.signal) { + const signal = options.signal; + if (signal.aborted) { + abort((signal: any).reason); + } else { + const listener = () => { + abort((signal: any).reason); + signal.removeEventListener('abort', listener); + }; + signal.addEventListener('abort', listener); + } + } + }); +} + +export {createFromFetch, createFromReadableStream, encodeReply}; + +if (__DEV__) { + injectIntoDevTools(); +} diff --git a/packages/react-flight-server-fb/src/server/ReactFlightDOMServerNode.js b/packages/react-flight-server-fb/src/server/ReactFlightDOMServerNode.js new file mode 100644 index 000000000000..eb481f0d772f --- /dev/null +++ b/packages/react-flight-server-fb/src/server/ReactFlightDOMServerNode.js @@ -0,0 +1,379 @@ +/** + * 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}; + +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(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(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( + busboyStream: Busboy, + options?: { + temporaryReferences?: TemporaryReferenceSet, + arraySizeLimit?: number, + }, +): Thenable { + const response = createResponse( + null, + '', + options ? options.temporaryReferences : undefined, + undefined, + options ? options.arraySizeLimit : undefined, + ); + let pendingFiles = 0; + const queuedFields: Array = []; + 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( + body: string | FormData, + options?: { + temporaryReferences?: TemporaryReferenceSet, + arraySizeLimit?: number, + }, +): Thenable { + 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(response); + close(response); + return root; +} + +export { + renderToPipeableStream, + decodeReply, + decodeReplyFromBusboy, + decodeAction, + decodeFormState, +}; diff --git a/packages/react-flight-server-fb/src/server/ReactFlightServerConfigDOMFB.js b/packages/react-flight-server-fb/src/server/ReactFlightServerConfigDOMFB.js new file mode 100644 index 000000000000..36e7e5932241 --- /dev/null +++ b/packages/react-flight-server-fb/src/server/ReactFlightServerConfigDOMFB.js @@ -0,0 +1,48 @@ +/** + * 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 + */ + +// Custom implementation of FlightServerConfigDOM. +// Resource hint dispatching is a no-op since we handle resource loading +// through our own runtime. + +// We keep the same type definitions so the Flight server protocol is compatible. + +// prettier-ignore +type TypeMap = { + 'D': string, + 'C': string | [string, string], + 'L': [string, string] | [string, string, any], + 'm': string | [string, any], + 'S': string | [string, string] | [string, string | 0, any], + 'X': string | [string, any], + 'M': string | [string, any], +} + +export type HintCode = $Keys; +export type HintModel = TypeMap[T]; + +export type Hints = Set; + +export function createHints(): Hints { + return new Set(); +} + +export opaque type FormatContext = number; + +export function createRootFormatContext(): FormatContext { + return 0; +} + +export function getChildFormatContext( + parentContext: FormatContext, + type: string, + props: Object, +): FormatContext { + return parentContext; +} diff --git a/packages/react-flight-server-fb/src/server/ReactFlightServerConfigFBBundler.js b/packages/react-flight-server-fb/src/server/ReactFlightServerConfigFBBundler.js new file mode 100644 index 000000000000..aeb3f772ba5c --- /dev/null +++ b/packages/react-flight-server-fb/src/server/ReactFlightServerConfigFBBundler.js @@ -0,0 +1,61 @@ +/** + * 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 {ReactClientValue} from 'react-server/src/ReactFlightServer'; + +import type { + ClientReference, + ServerReference, +} from '../ReactFlightFBReferences'; + +export type {ClientReference, ServerReference}; + +export type ClientManifest = null; + +export type ServerReferenceId = string; + +export type ClientReferenceMetadata = ClientReference; + +export type ClientReferenceKey = string; + +export {isClientReference, isServerReference} from '../ReactFlightFBReferences'; + +export function getClientReferenceKey( + reference: ClientReference, +): ClientReferenceKey { + return reference.$$id; +} + +export function resolveClientReferenceMetadata( + config: ClientManifest, + clientReference: ClientReference, +): ClientReferenceMetadata { + return clientReference; +} + +export function getServerReferenceId( + config: ClientManifest, + serverReference: ServerReference, +): ServerReferenceId { + return serverReference.$$id; +} + +export function getServerReferenceBoundArguments( + config: ClientManifest, + serverReference: ServerReference, +): null | Array { + return serverReference.$$bound; +} + +export function getServerReferenceLocation( + config: ClientManifest, + serverReference: ServerReference, +): void | Error { + return serverReference.$$location; +} diff --git a/packages/react-flight-server-fb/src/server/react-flight-dom-server.node.js b/packages/react-flight-server-fb/src/server/react-flight-dom-server.node.js new file mode 100644 index 000000000000..cd13ae68f43d --- /dev/null +++ b/packages/react-flight-server-fb/src/server/react-flight-dom-server.node.js @@ -0,0 +1,18 @@ +/** + * 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 + */ + +export { + renderToPipeableStream, + decodeReply, + decodeAction, + decodeFormState, + registerServerReference, + registerClientReference, + createTemporaryReferenceSet, +} from './ReactFlightDOMServerNode'; diff --git a/packages/react-server/src/forks/ReactFlightServerConfig.dom-browser-fb.js b/packages/react-server/src/forks/ReactFlightServerConfig.dom-browser-fb.js new file mode 100644 index 000000000000..9dbd288d3da5 --- /dev/null +++ b/packages/react-server/src/forks/ReactFlightServerConfig.dom-browser-fb.js @@ -0,0 +1,26 @@ +/** + * 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} from 'react-server/src/ReactFlightServer'; +import type {ReactComponentInfo} from 'shared/ReactTypes'; + +export * from 'react-flight-server-fb/src/server/ReactFlightServerConfigFBBundler'; +export * from 'react-flight-server-fb/src/server/ReactFlightServerConfigDOMFB'; + +export const supportsRequestStorage = false; +export const requestStorage: AsyncLocalStorage = (null: any); + +export const supportsComponentStorage = false; +export const componentStorage: AsyncLocalStorage = + (null: any); + +export * from '../ReactFlightServerConfigDebugNoop'; + +export * from '../ReactFlightStackConfigV8'; +export * from '../ReactServerConsoleConfigBrowser'; diff --git a/packages/react-server/src/forks/ReactFlightServerConfig.dom-node-fb.js b/packages/react-server/src/forks/ReactFlightServerConfig.dom-node-fb.js new file mode 100644 index 000000000000..2a69070d027e --- /dev/null +++ b/packages/react-server/src/forks/ReactFlightServerConfig.dom-node-fb.js @@ -0,0 +1,26 @@ +/** + * 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} from 'react-server/src/ReactFlightServer'; +import type {ReactComponentInfo} from 'shared/ReactTypes'; + +export * from 'react-flight-server-fb/src/server/ReactFlightServerConfigFBBundler'; +export * from 'react-flight-server-fb/src/server/ReactFlightServerConfigDOMFB'; + +export const supportsRequestStorage = false; +export const requestStorage: AsyncLocalStorage = (null: any); + +export const supportsComponentStorage = false; +export const componentStorage: AsyncLocalStorage = + (null: any); + +export * from '../ReactFlightServerConfigDebugNoop'; + +export * from '../ReactFlightStackConfigV8'; +export * from '../ReactServerConsoleConfigServer'; diff --git a/packages/react-server/src/forks/ReactServerStreamConfig.dom-browser-fb.js b/packages/react-server/src/forks/ReactServerStreamConfig.dom-browser-fb.js new file mode 100644 index 000000000000..9dd935284cf6 --- /dev/null +++ b/packages/react-server/src/forks/ReactServerStreamConfig.dom-browser-fb.js @@ -0,0 +1,10 @@ +/** + * 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 + */ + +export * from 'react-flight-server-fb/src/ReactServerStreamConfigFB'; diff --git a/packages/react-server/src/forks/ReactServerStreamConfig.dom-node-fb.js b/packages/react-server/src/forks/ReactServerStreamConfig.dom-node-fb.js new file mode 100644 index 000000000000..9dd935284cf6 --- /dev/null +++ b/packages/react-server/src/forks/ReactServerStreamConfig.dom-node-fb.js @@ -0,0 +1,10 @@ +/** + * 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 + */ + +export * from 'react-flight-server-fb/src/ReactServerStreamConfigFB'; diff --git a/scripts/rollup/bundles.js b/scripts/rollup/bundles.js index 25e5f31bf9ef..acd1846b3997 100644 --- a/scripts/rollup/bundles.js +++ b/scripts/rollup/bundles.js @@ -756,6 +756,29 @@ const bundles = [ externals: ['acorn'], }, + /******* React Flight Server FB Server *******/ + { + bundleTypes: [FB_WWW_DEV, FB_WWW_PROD], + moduleType: RENDERER, + entry: 'react-flight-server-fb/src/server/react-flight-dom-server.node', + global: 'ReactFlightServer', + condition: 'react-server', + minifyWithProdErrorCodes: false, + wrapWithModuleBoundaries: false, + externals: ['react', 'react-dom', 'stream'], + }, + + /******* React Flight Client FB *******/ + { + bundleTypes: [FB_WWW_DEV, FB_WWW_PROD], + moduleType: RENDERER, + entry: 'react-flight-server-fb/client.browser', + global: 'ReactFlightClient', + minifyWithProdErrorCodes: false, + wrapWithModuleBoundaries: false, + externals: ['react', 'react-dom'], + }, + /******* React Server DOM Unbundled Server *******/ { bundleTypes: [NODE_DEV, NODE_PROD], diff --git a/scripts/shared/inlinedHostConfigs.js b/scripts/shared/inlinedHostConfigs.js index 631c6585f31b..aa9e7fc6d61d 100644 --- a/scripts/shared/inlinedHostConfigs.js +++ b/scripts/shared/inlinedHostConfigs.js @@ -326,6 +326,36 @@ module.exports = [ isFlowTyped: true, isServerSupported: true, }, + { + shortName: 'dom-browser-fb', + entryPoints: ['react-flight-server-fb/client.browser'], + paths: [ + 'react-dom', + 'react-dom/src/ReactDOMReactServer.js', + 'react-dom/client', + 'react-dom/profiling', + 'react-dom/server', + 'react-dom/server.node', + 'react-dom-bindings', + 'react-dom-bindings/src/server/ReactDOMFlightServerHostDispatcher.js', + 'react-dom-bindings/src/server/ReactFlightServerConfigDOM.js', + 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM.js', + 'react-flight-server-fb', + 'react-flight-server-fb/client', + 'react-flight-server-fb/client.browser', + 'react-flight-server-fb/src/client/ReactFlightDOMClientBrowser.js', // react-flight-server-fb/client.browser + 'react-flight-server-fb/src/server/react-flight-dom-server.node', + 'react-flight-server-fb/src/server/ReactFlightDOMServerNode.js', // react-flight-server-fb/src/server/react-flight-dom-server.node + 'react-devtools', + 'react-devtools-core', + 'react-devtools-shell', + 'react-devtools-shared', + 'shared/ReactDOMSharedInternals', + 'react-server/src/ReactFlightServerConfigDebugNoop.js', + ], + isFlowTyped: true, + isServerSupported: true, + }, { shortName: 'dom-browser-turbopack', entryPoints: [ @@ -564,6 +594,40 @@ module.exports = [ isFlowTyped: true, isServerSupported: true, }, + { + shortName: 'dom-node-fb', + entryPoints: [ + 'react-flight-server-fb/src/server/react-flight-dom-server.node', + ], + paths: [ + 'react-dom', + 'react-dom/src/ReactDOMReactServer.js', + 'react-dom-bindings', + 'react-dom/client', + 'react-dom/profiling', + 'react-dom/server', + 'react-dom/server.node', + 'react-dom/static', + 'react-dom/static.node', + 'react-dom/src/server/react-dom-server.node', + 'react-dom/src/server/ReactDOMFizzServerNode.js', // react-dom/server.node + 'react-dom/src/server/ReactDOMFizzStaticNode.js', + 'react-dom-bindings/src/server/ReactDOMFlightServerHostDispatcher.js', + 'react-dom-bindings/src/server/ReactFlightServerConfigDOM.js', + 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM.js', + 'react-flight-server-fb', + 'react-flight-server-fb/src/server/react-flight-dom-server.node', + 'react-flight-server-fb/src/server/ReactFlightDOMServerNode.js', // react-flight-server-fb/src/server/react-flight-dom-server.node + 'react-devtools', + 'react-devtools-core', + 'react-devtools-shell', + 'react-devtools-shared', + 'shared/ReactDOMSharedInternals', + 'react-server/src/ReactFlightServerConfigDebugNoop.js', + ], + isFlowTyped: true, + isServerSupported: true, + }, { shortName: 'dom-legacy', entryPoints: [