From 01a0c4e12c6aa9732d290e13b1452f72d276934d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Tue, 7 Feb 2023 15:10:01 -0500 Subject: [PATCH] Add Edge Server Builds for workerd / edge-light (#26116) We currently abuse the browser builds for Web streams derived environments. We already have a special build for Bun but we should also have one for [other "edge" runtimes](https://runtime-keys.proposal.wintercg.org/) so that we can maximally take advantage of the APIs that exist on each platform. In practice, we currently check for a global property called `AsyncLocalStorage` in the server browser builds which we shouldn't really do since browsers likely won't ever have it. Additionally, this should probably move to an import which we can't add to actual browser builds where that will be an invalid import. So it has to be a separate build. That's not done yet in this PR but Vercel will follow Cloudflare's lead here. The `deno` key still points to the browser build since there's no AsyncLocalStorage there but it could use this same or a custom build if support is added. --- .../ReactFlightClientHostConfig.dom-edge.js | 12 ++ packages/react-dom/npm/server.edge.js | 18 ++ packages/react-dom/npm/static.edge.js | 7 + packages/react-dom/package.json | 8 + packages/react-dom/server.edge.js | 47 +++++ .../src/server/ReactDOMFizzServerEdge.js | 116 +++++++++++++ .../src/server/ReactDOMFizzStaticEdge.js | 101 +++++++++++ packages/react-dom/static.edge.js | 10 ++ .../forks/ReactFiberHostConfig.dom-edge.js | 10 ++ .../npm/server.edge.js | 7 + .../react-server-dom-webpack/package.json | 7 +- .../react-server-dom-webpack/server.edge.js | 10 ++ .../src/ReactFlightDOMServerEdge.js | 69 ++++++++ .../src/ReactServerStreamConfigBrowser.js | 6 +- .../src/ReactServerStreamConfigEdge.js | 161 ++++++++++++++++++ .../forks/ReactFlightServerConfig.dom-edge.js | 11 ++ .../forks/ReactServerFormatConfig.dom-edge.js | 10 ++ .../forks/ReactServerStreamConfig.dom-edge.js | 10 ++ .../react/src/__tests__/ReactFetch-test.js | 2 - .../src/__tests__/ReactFetchEdge-test.js | 80 +++++++++ scripts/rollup/bundles.js | 31 ++++ scripts/shared/inlinedHostConfigs.js | 35 ++++ 22 files changed, 761 insertions(+), 7 deletions(-) create mode 100644 packages/react-client/src/forks/ReactFlightClientHostConfig.dom-edge.js create mode 100644 packages/react-dom/npm/server.edge.js create mode 100644 packages/react-dom/npm/static.edge.js create mode 100644 packages/react-dom/server.edge.js create mode 100644 packages/react-dom/src/server/ReactDOMFizzServerEdge.js create mode 100644 packages/react-dom/src/server/ReactDOMFizzStaticEdge.js create mode 100644 packages/react-dom/static.edge.js create mode 100644 packages/react-reconciler/src/forks/ReactFiberHostConfig.dom-edge.js create mode 100644 packages/react-server-dom-webpack/npm/server.edge.js create mode 100644 packages/react-server-dom-webpack/server.edge.js create mode 100644 packages/react-server-dom-webpack/src/ReactFlightDOMServerEdge.js create mode 100644 packages/react-server/src/ReactServerStreamConfigEdge.js create mode 100644 packages/react-server/src/forks/ReactFlightServerConfig.dom-edge.js create mode 100644 packages/react-server/src/forks/ReactServerFormatConfig.dom-edge.js create mode 100644 packages/react-server/src/forks/ReactServerStreamConfig.dom-edge.js create mode 100644 packages/react/src/__tests__/ReactFetchEdge-test.js diff --git a/packages/react-client/src/forks/ReactFlightClientHostConfig.dom-edge.js b/packages/react-client/src/forks/ReactFlightClientHostConfig.dom-edge.js new file mode 100644 index 0000000000000..4aae8141fd56e --- /dev/null +++ b/packages/react-client/src/forks/ReactFlightClientHostConfig.dom-edge.js @@ -0,0 +1,12 @@ +/** + * 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-client/src/ReactFlightClientHostConfigBrowser'; +export * from 'react-client/src/ReactFlightClientHostConfigStream'; +export * from 'react-server-dom-webpack/src/ReactFlightClientWebpackBundlerConfig'; diff --git a/packages/react-dom/npm/server.edge.js b/packages/react-dom/npm/server.edge.js new file mode 100644 index 0000000000000..9b46751ab208f --- /dev/null +++ b/packages/react-dom/npm/server.edge.js @@ -0,0 +1,18 @@ +'use strict'; + +var b; +var l; +if (process.env.NODE_ENV === 'production') { + b = require('./cjs/react-dom-server.edge.production.min.js'); + l = require('./cjs/react-dom-server-legacy.browser.production.min.js'); +} else { + b = require('./cjs/react-dom-server.edge.development.js'); + l = require('./cjs/react-dom-server-legacy.browser.development.js'); +} + +exports.version = b.version; +exports.renderToReadableStream = b.renderToReadableStream; +exports.renderToNodeStream = b.renderToNodeStream; +exports.renderToStaticNodeStream = b.renderToStaticNodeStream; +exports.renderToString = l.renderToString; +exports.renderToStaticMarkup = l.renderToStaticMarkup; diff --git a/packages/react-dom/npm/static.edge.js b/packages/react-dom/npm/static.edge.js new file mode 100644 index 0000000000000..9202efd20cf3f --- /dev/null +++ b/packages/react-dom/npm/static.edge.js @@ -0,0 +1,7 @@ +'use strict'; + +if (process.env.NODE_ENV === 'production') { + module.exports = require('./cjs/react-dom-static.edge.production.min.js'); +} else { + module.exports = require('./cjs/react-dom-static.edge.development.js'); +} diff --git a/packages/react-dom/package.json b/packages/react-dom/package.json index 3ee587be55434..d38382b9f2717 100644 --- a/packages/react-dom/package.json +++ b/packages/react-dom/package.json @@ -31,10 +31,12 @@ "profiling.js", "server.js", "server.browser.js", + "server.edge.js", "server.node.js", "server.bun.js", "static.js", "static.browser.js", + "static.edge.js", "static.node.js", "server-rendering-stub.js", "test-utils.js", @@ -47,6 +49,8 @@ ".": "./index.js", "./client": "./client.js", "./server": { + "workerd": "./server.edge.js", + "edge-light": "./server.edge.js", "bun": "./server.bun.js", "deno": "./server.browser.js", "worker": "./server.browser.js", @@ -54,14 +58,18 @@ "default": "./server.node.js" }, "./server.browser": "./server.browser.js", + "./server.edge": "./server.edge.js", "./server.node": "./server.node.js", "./static": { + "workerd": "./static.edge.js", + "edge-light": "./static.edge.js", "deno": "./static.browser.js", "worker": "./static.browser.js", "browser": "./static.browser.js", "default": "./static.node.js" }, "./static.browser": "./static.browser.js", + "./static.edge": "./static.edge.js", "./static.node": "./static.node.js", "./server-rendering-stub": "./server-rendering-stub.js", "./profiling": "./profiling.js", diff --git a/packages/react-dom/server.edge.js b/packages/react-dom/server.edge.js new file mode 100644 index 0000000000000..f45eae21e7d49 --- /dev/null +++ b/packages/react-dom/server.edge.js @@ -0,0 +1,47 @@ +/** + * 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. + */ + +// This file is only used for tests. +// It lazily loads the implementation so that we get the correct set of host configs. + +import ReactVersion from 'shared/ReactVersion'; +export {ReactVersion as version}; + +export function renderToReadableStream() { + return require('./src/server/ReactDOMFizzServerEdge').renderToReadableStream.apply( + this, + arguments, + ); +} + +export function renderToNodeStream() { + return require('./src/server/ReactDOMFizzServerEdge').renderToNodeStream.apply( + this, + arguments, + ); +} + +export function renderToStaticNodeStream() { + return require('./src/server/ReactDOMFizzServerEdge').renderToStaticNodeStream.apply( + this, + arguments, + ); +} + +export function renderToString() { + return require('./src/server/ReactDOMLegacyServerBrowser').renderToString.apply( + this, + arguments, + ); +} + +export function renderToStaticMarkup() { + return require('./src/server/ReactDOMLegacyServerBrowser').renderToStaticMarkup.apply( + this, + arguments, + ); +} diff --git a/packages/react-dom/src/server/ReactDOMFizzServerEdge.js b/packages/react-dom/src/server/ReactDOMFizzServerEdge.js new file mode 100644 index 0000000000000..59576a127ea78 --- /dev/null +++ b/packages/react-dom/src/server/ReactDOMFizzServerEdge.js @@ -0,0 +1,116 @@ +/** + * 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 {ReactNodeList} from 'shared/ReactTypes'; +import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/ReactDOMServerFormatConfig'; + +import ReactVersion from 'shared/ReactVersion'; + +import { + createRequest, + startWork, + startFlowing, + abort, +} from 'react-server/src/ReactFizzServer'; + +import { + createResponseState, + createRootFormatContext, +} from 'react-dom-bindings/src/server/ReactDOMServerFormatConfig'; + +type Options = { + identifierPrefix?: string, + namespaceURI?: string, + nonce?: string, + bootstrapScriptContent?: string, + bootstrapScripts?: Array, + bootstrapModules?: Array, + progressiveChunkSize?: number, + signal?: AbortSignal, + onError?: (error: mixed) => ?string, + unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor, +}; + +// TODO: Move to sub-classing ReadableStream. +type ReactDOMServerReadableStream = ReadableStream & { + allReady: Promise, +}; + +function renderToReadableStream( + children: ReactNodeList, + options?: Options, +): Promise { + return new Promise((resolve, reject) => { + let onFatalError; + let onAllReady; + const allReady = new Promise((res, rej) => { + onAllReady = res; + onFatalError = rej; + }); + + function onShellReady() { + const stream: ReactDOMServerReadableStream = (new ReadableStream( + { + type: 'bytes', + pull: (controller): ?Promise => { + startFlowing(request, controller); + }, + cancel: (reason): ?Promise => { + abort(request); + }, + }, + // $FlowFixMe size() methods are not allowed on byte streams. + {highWaterMark: 0}, + ): any); + // TODO: Move to sub-classing ReadableStream. + stream.allReady = allReady; + resolve(stream); + } + function onShellError(error: mixed) { + // If the shell errors the caller of `renderToReadableStream` won't have access to `allReady`. + // However, `allReady` will be rejected by `onFatalError` as well. + // So we need to catch the duplicate, uncatchable fatal error in `allReady` to prevent a `UnhandledPromiseRejection`. + allReady.catch(() => {}); + reject(error); + } + const request = createRequest( + children, + createResponseState( + options ? options.identifierPrefix : undefined, + options ? options.nonce : undefined, + options ? options.bootstrapScriptContent : undefined, + options ? options.bootstrapScripts : undefined, + options ? options.bootstrapModules : undefined, + options ? options.unstable_externalRuntimeSrc : undefined, + ), + createRootFormatContext(options ? options.namespaceURI : undefined), + options ? options.progressiveChunkSize : undefined, + options ? options.onError : undefined, + onAllReady, + onShellReady, + onShellError, + onFatalError, + ); + if (options && options.signal) { + const signal = options.signal; + if (signal.aborted) { + abort(request, (signal: any).reason); + } else { + const listener = () => { + abort(request, (signal: any).reason); + signal.removeEventListener('abort', listener); + }; + signal.addEventListener('abort', listener); + } + } + startWork(request); + }); +} + +export {renderToReadableStream, ReactVersion as version}; diff --git a/packages/react-dom/src/server/ReactDOMFizzStaticEdge.js b/packages/react-dom/src/server/ReactDOMFizzStaticEdge.js new file mode 100644 index 0000000000000..7f88593dd50f8 --- /dev/null +++ b/packages/react-dom/src/server/ReactDOMFizzStaticEdge.js @@ -0,0 +1,101 @@ +/** + * 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 {ReactNodeList} from 'shared/ReactTypes'; +import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/ReactDOMServerFormatConfig'; + +import ReactVersion from 'shared/ReactVersion'; + +import { + createRequest, + startWork, + startFlowing, + abort, +} from 'react-server/src/ReactFizzServer'; + +import { + createResponseState, + createRootFormatContext, +} from 'react-dom-bindings/src/server/ReactDOMServerFormatConfig'; + +type Options = { + identifierPrefix?: string, + namespaceURI?: string, + bootstrapScriptContent?: string, + bootstrapScripts?: Array, + bootstrapModules?: Array, + progressiveChunkSize?: number, + signal?: AbortSignal, + onError?: (error: mixed) => ?string, + unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor, +}; + +type StaticResult = { + prelude: ReadableStream, +}; + +function prerender( + children: ReactNodeList, + options?: Options, +): Promise { + return new Promise((resolve, reject) => { + const onFatalError = reject; + + function onAllReady() { + const stream = new ReadableStream( + { + type: 'bytes', + pull: (controller): ?Promise => { + startFlowing(request, controller); + }, + }, + // $FlowFixMe size() methods are not allowed on byte streams. + {highWaterMark: 0}, + ); + + const result = { + prelude: stream, + }; + resolve(result); + } + const request = createRequest( + children, + createResponseState( + options ? options.identifierPrefix : undefined, + undefined, + options ? options.bootstrapScriptContent : undefined, + options ? options.bootstrapScripts : undefined, + options ? options.bootstrapModules : undefined, + options ? options.unstable_externalRuntimeSrc : undefined, + ), + createRootFormatContext(options ? options.namespaceURI : undefined), + options ? options.progressiveChunkSize : undefined, + options ? options.onError : undefined, + onAllReady, + undefined, + undefined, + onFatalError, + ); + if (options && options.signal) { + const signal = options.signal; + if (signal.aborted) { + abort(request, (signal: any).reason); + } else { + const listener = () => { + abort(request, (signal: any).reason); + signal.removeEventListener('abort', listener); + }; + signal.addEventListener('abort', listener); + } + } + startWork(request); + }); +} + +export {prerender, ReactVersion as version}; diff --git a/packages/react-dom/static.edge.js b/packages/react-dom/static.edge.js new file mode 100644 index 0000000000000..74361b0e6dedf --- /dev/null +++ b/packages/react-dom/static.edge.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 {prerender, version} from './src/server/ReactDOMFizzStaticEdge'; diff --git a/packages/react-reconciler/src/forks/ReactFiberHostConfig.dom-edge.js b/packages/react-reconciler/src/forks/ReactFiberHostConfig.dom-edge.js new file mode 100644 index 0000000000000..aae45be9aae50 --- /dev/null +++ b/packages/react-reconciler/src/forks/ReactFiberHostConfig.dom-edge.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-dom-bindings/src/client/ReactDOMHostConfig'; diff --git a/packages/react-server-dom-webpack/npm/server.edge.js b/packages/react-server-dom-webpack/npm/server.edge.js new file mode 100644 index 0000000000000..d061fe624e78f --- /dev/null +++ b/packages/react-server-dom-webpack/npm/server.edge.js @@ -0,0 +1,7 @@ +'use strict'; + +if (process.env.NODE_ENV === 'production') { + module.exports = require('./cjs/react-server-dom-webpack-server.edge.production.min.js'); +} else { + module.exports = require('./cjs/react-server-dom-webpack-server.edge.development.js'); +} diff --git a/packages/react-server-dom-webpack/package.json b/packages/react-server-dom-webpack/package.json index f68394b92fc4b..8bf5e8c18e19e 100644 --- a/packages/react-server-dom-webpack/package.json +++ b/packages/react-server-dom-webpack/package.json @@ -16,6 +16,7 @@ "client.js", "server.js", "server.browser.js", + "server.edge.js", "server.node.js", "node-register.js", "cjs/", @@ -28,13 +29,17 @@ "./client": "./client.js", "./server": { "react-server": { + "edge-light": "./server.edge.js", + "workerd": "./server.edge.js", + "deno": "./server.browser.js", "node": "./server.node.js", "browser": "./server.browser.js" }, "default": "./server.js" }, - "./server.node": "./server.node.js", "./server.browser": "./server.browser.js", + "./server.edge": "./server.edge.js", + "./server.node": "./server.node.js", "./node-loader": "./esm/react-server-dom-webpack-node-loader.js", "./node-register": "./node-register.js", "./src/*": "./src/*", diff --git a/packages/react-server-dom-webpack/server.edge.js b/packages/react-server-dom-webpack/server.edge.js new file mode 100644 index 0000000000000..98f975cb4706f --- /dev/null +++ b/packages/react-server-dom-webpack/server.edge.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/ReactFlightDOMServerEdge'; diff --git a/packages/react-server-dom-webpack/src/ReactFlightDOMServerEdge.js b/packages/react-server-dom-webpack/src/ReactFlightDOMServerEdge.js new file mode 100644 index 0000000000000..be2343121e2a0 --- /dev/null +++ b/packages/react-server-dom-webpack/src/ReactFlightDOMServerEdge.js @@ -0,0 +1,69 @@ +/** + * 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 {ReactModel} from 'react-server/src/ReactFlightServer'; +import type {ServerContextJSONValue} from 'shared/ReactTypes'; +import type {BundlerConfig} from './ReactFlightServerWebpackBundlerConfig'; + +import { + createRequest, + startWork, + startFlowing, + abort, +} from 'react-server/src/ReactFlightServer'; + +type Options = { + identifierPrefix?: string, + signal?: AbortSignal, + context?: Array<[string, ServerContextJSONValue]>, + onError?: (error: mixed) => void, +}; + +function renderToReadableStream( + model: ReactModel, + webpackMap: BundlerConfig, + options?: Options, +): ReadableStream { + const request = createRequest( + model, + webpackMap, + options ? options.onError : undefined, + options ? options.context : undefined, + options ? options.identifierPrefix : undefined, + ); + if (options && options.signal) { + const signal = options.signal; + if (signal.aborted) { + abort(request, (signal: any).reason); + } else { + const listener = () => { + abort(request, (signal: any).reason); + signal.removeEventListener('abort', listener); + }; + signal.addEventListener('abort', listener); + } + } + const stream = new ReadableStream( + { + type: 'bytes', + start: (controller): ?Promise => { + startWork(request); + }, + pull: (controller): ?Promise => { + startFlowing(request, controller); + }, + cancel: (reason): ?Promise => {}, + }, + // $FlowFixMe size() methods are not allowed on byte streams. + {highWaterMark: 0}, + ); + return stream; +} + +export {renderToReadableStream}; diff --git a/packages/react-server/src/ReactServerStreamConfigBrowser.js b/packages/react-server/src/ReactServerStreamConfigBrowser.js index 22ac22f85d569..443b5e7866bbe 100644 --- a/packages/react-server/src/ReactServerStreamConfigBrowser.js +++ b/packages/react-server/src/ReactServerStreamConfigBrowser.js @@ -21,11 +21,9 @@ export function flushBuffered(destination: Destination) { // transform streams. https://github.com/whatwg/streams/issues/960 } -// For now we support AsyncLocalStorage as a global for the "browser" builds -// TODO: Move this to some special WinterCG build. -export const supportsRequestStorage = typeof AsyncLocalStorage === 'function'; +export const supportsRequestStorage = false; export const requestStorage: AsyncLocalStorage> = - supportsRequestStorage ? new AsyncLocalStorage() : (null: any); + (null: any); const VIEW_SIZE = 512; let currentView = null; diff --git a/packages/react-server/src/ReactServerStreamConfigEdge.js b/packages/react-server/src/ReactServerStreamConfigEdge.js new file mode 100644 index 0000000000000..a898e6b8d40fe --- /dev/null +++ b/packages/react-server/src/ReactServerStreamConfigEdge.js @@ -0,0 +1,161 @@ +/** + * 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 Destination = ReadableStreamController; + +export type PrecomputedChunk = Uint8Array; +export opaque type Chunk = Uint8Array; + +export function scheduleWork(callback: () => void) { + callback(); +} + +export function flushBuffered(destination: Destination) { + // WHATWG Streams do not yet have a way to flush the underlying + // transform streams. https://github.com/whatwg/streams/issues/960 +} + +// For now, we get this from the global scope, but this will likely move to a module. +export const supportsRequestStorage = typeof AsyncLocalStorage === 'function'; +export const requestStorage: AsyncLocalStorage> = + supportsRequestStorage ? new AsyncLocalStorage() : (null: any); + +const VIEW_SIZE = 512; +let currentView = null; +let writtenBytes = 0; + +export function beginWriting(destination: Destination) { + currentView = new Uint8Array(VIEW_SIZE); + writtenBytes = 0; +} + +export function writeChunk( + destination: Destination, + chunk: PrecomputedChunk | Chunk, +): void { + if (chunk.length === 0) { + return; + } + + if (chunk.length > VIEW_SIZE) { + if (__DEV__) { + if (precomputedChunkSet.has(chunk)) { + console.error( + 'A large precomputed chunk was passed to writeChunk without being copied.' + + ' Large chunks get enqueued directly and are not copied. This is incompatible with precomputed chunks because you cannot enqueue the same precomputed chunk twice.' + + ' Use "cloneChunk" to make a copy of this large precomputed chunk before writing it. This is a bug in React.', + ); + } + } + // this chunk may overflow a single view which implies it was not + // one that is cached by the streaming renderer. We will enqueu + // it directly and expect it is not re-used + if (writtenBytes > 0) { + destination.enqueue( + new Uint8Array( + ((currentView: any): Uint8Array).buffer, + 0, + writtenBytes, + ), + ); + currentView = new Uint8Array(VIEW_SIZE); + writtenBytes = 0; + } + destination.enqueue(chunk); + return; + } + + let bytesToWrite = chunk; + const allowableBytes = ((currentView: any): Uint8Array).length - writtenBytes; + if (allowableBytes < bytesToWrite.length) { + // this chunk would overflow the current view. We enqueue a full view + // and start a new view with the remaining chunk + if (allowableBytes === 0) { + // the current view is already full, send it + destination.enqueue(currentView); + } else { + // fill up the current view and apply the remaining chunk bytes + // to a new view. + ((currentView: any): Uint8Array).set( + bytesToWrite.subarray(0, allowableBytes), + writtenBytes, + ); + // writtenBytes += allowableBytes; // this can be skipped because we are going to immediately reset the view + destination.enqueue(currentView); + bytesToWrite = bytesToWrite.subarray(allowableBytes); + } + currentView = new Uint8Array(VIEW_SIZE); + writtenBytes = 0; + } + ((currentView: any): Uint8Array).set(bytesToWrite, writtenBytes); + writtenBytes += bytesToWrite.length; +} + +export function writeChunkAndReturn( + destination: Destination, + chunk: PrecomputedChunk | Chunk, +): boolean { + writeChunk(destination, chunk); + // in web streams there is no backpressure so we can alwas write more + return true; +} + +export function completeWriting(destination: Destination) { + if (currentView && writtenBytes > 0) { + destination.enqueue(new Uint8Array(currentView.buffer, 0, writtenBytes)); + currentView = null; + writtenBytes = 0; + } +} + +export function close(destination: Destination) { + destination.close(); +} + +const textEncoder = new TextEncoder(); + +export function stringToChunk(content: string): Chunk { + return textEncoder.encode(content); +} + +const precomputedChunkSet: Set = __DEV__ ? new Set() : (null: any); + +export function stringToPrecomputedChunk(content: string): PrecomputedChunk { + const precomputedChunk = textEncoder.encode(content); + + if (__DEV__) { + precomputedChunkSet.add(precomputedChunk); + } + + return precomputedChunk; +} + +export function clonePrecomputedChunk( + precomputedChunk: PrecomputedChunk, +): PrecomputedChunk { + return precomputedChunk.length > VIEW_SIZE + ? precomputedChunk.slice() + : precomputedChunk; +} + +export function closeWithError(destination: Destination, error: mixed): void { + // $FlowFixMe[method-unbinding] + if (typeof destination.error === 'function') { + // $FlowFixMe: This is an Error object or the destination accepts other types. + destination.error(error); + } else { + // Earlier implementations doesn't support this method. In that environment you're + // supposed to throw from a promise returned but we don't return a promise in our + // approach. We could fork this implementation but this is environment is an edge + // case to begin with. It's even less common to run this in an older environment. + // Even then, this is not where errors are supposed to happen and they get reported + // to a global callback in addition to this anyway. So it's fine just to close this. + destination.close(); + } +} diff --git a/packages/react-server/src/forks/ReactFlightServerConfig.dom-edge.js b/packages/react-server/src/forks/ReactFlightServerConfig.dom-edge.js new file mode 100644 index 0000000000000..99c541a937d63 --- /dev/null +++ b/packages/react-server/src/forks/ReactFlightServerConfig.dom-edge.js @@ -0,0 +1,11 @@ +/** + * 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 '../ReactFlightServerConfigStream'; +export * from 'react-server-dom-webpack/src/ReactFlightServerWebpackBundlerConfig'; diff --git a/packages/react-server/src/forks/ReactServerFormatConfig.dom-edge.js b/packages/react-server/src/forks/ReactServerFormatConfig.dom-edge.js new file mode 100644 index 0000000000000..485793a6893ee --- /dev/null +++ b/packages/react-server/src/forks/ReactServerFormatConfig.dom-edge.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-dom-bindings/src/server/ReactDOMServerFormatConfig'; diff --git a/packages/react-server/src/forks/ReactServerStreamConfig.dom-edge.js b/packages/react-server/src/forks/ReactServerStreamConfig.dom-edge.js new file mode 100644 index 0000000000000..a594d19afc749 --- /dev/null +++ b/packages/react-server/src/forks/ReactServerStreamConfig.dom-edge.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 '../ReactServerStreamConfigEdge'; diff --git a/packages/react/src/__tests__/ReactFetch-test.js b/packages/react/src/__tests__/ReactFetch-test.js index 00f274d5c0da1..753cb03324991 100644 --- a/packages/react/src/__tests__/ReactFetch-test.js +++ b/packages/react/src/__tests__/ReactFetch-test.js @@ -17,8 +17,6 @@ global.TextDecoder = require('util').TextDecoder; global.Headers = require('node-fetch').Headers; global.Request = require('node-fetch').Request; global.Response = require('node-fetch').Response; -// Patch for Browser environments to be able to polyfill AsyncLocalStorage -global.AsyncLocalStorage = require('async_hooks').AsyncLocalStorage; let fetchCount = 0; async function fetchMock(resource, options) { diff --git a/packages/react/src/__tests__/ReactFetchEdge-test.js b/packages/react/src/__tests__/ReactFetchEdge-test.js new file mode 100644 index 0000000000000..10484b853bf50 --- /dev/null +++ b/packages/react/src/__tests__/ReactFetchEdge-test.js @@ -0,0 +1,80 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react-core + */ + +'use strict'; + +// Polyfills for test environment +global.ReadableStream = + require('web-streams-polyfill/ponyfill/es6').ReadableStream; +global.TextEncoder = require('util').TextEncoder; +global.TextDecoder = require('util').TextDecoder; +global.Headers = require('node-fetch').Headers; +global.Request = require('node-fetch').Request; +global.Response = require('node-fetch').Response; +// Patch for Edge environments for global scope +global.AsyncLocalStorage = require('async_hooks').AsyncLocalStorage; + +let fetchCount = 0; +async function fetchMock(resource, options) { + fetchCount++; + const request = new Request(resource, options); + return new Response( + request.method + + ' ' + + request.url + + ' ' + + JSON.stringify(Array.from(request.headers.entries())), + ); +} + +let React; +let ReactServerDOMServer; +let ReactServerDOMClient; +let use; + +describe('ReactFetch', () => { + beforeEach(() => { + jest.resetModules(); + fetchCount = 0; + global.fetch = fetchMock; + + if (gate(flags => !flags.www)) { + jest.mock('react', () => require('react/react.shared-subset')); + } + + React = require('react'); + ReactServerDOMServer = require('react-server-dom-webpack/server.edge'); + ReactServerDOMClient = require('react-server-dom-webpack/client'); + use = React.use; + }); + + async function render(Component) { + const stream = ReactServerDOMServer.renderToReadableStream(); + return ReactServerDOMClient.createFromReadableStream(stream); + } + + // @gate enableFetchInstrumentation && enableCache + it('can dedupe fetches separately in interleaved renders', async () => { + async function getData() { + const r1 = await fetch('hi'); + const t1 = await r1.text(); + const r2 = await fetch('hi'); + const t2 = await r2.text(); + return t1 + ' ' + t2; + } + function Component() { + return use(getData()); + } + const render1 = render(Component); + const render2 = render(Component); + expect(await render1).toMatchInlineSnapshot(`"GET hi [] GET hi []"`); + expect(await render2).toMatchInlineSnapshot(`"GET hi [] GET hi []"`); + expect(fetchCount).toBe(2); + }); +}); diff --git a/scripts/rollup/bundles.js b/scripts/rollup/bundles.js index 2b81e666732fa..3b97f2ef2c6b7 100644 --- a/scripts/rollup/bundles.js +++ b/scripts/rollup/bundles.js @@ -258,6 +258,19 @@ const bundles = [ externals: ['react', 'react-dom'], }, + /******* React DOM Fizz Server Edge *******/ + { + bundleTypes: [NODE_DEV, NODE_PROD], + moduleType: RENDERER, + entry: 'react-dom/src/server/ReactDOMFizzServerEdge.js', + name: 'react-dom-server.edge', // 'node_modules/react/*.js', + + global: 'ReactDOMServer', + minifyWithProdErrorCodes: false, + wrapWithModuleBoundaries: false, + externals: ['react', 'react-dom'], + }, + /******* React DOM Fizz Server Bun *******/ { bundleTypes: [BUN_DEV, BUN_PROD], @@ -291,6 +304,15 @@ const bundles = [ wrapWithModuleBoundaries: false, externals: ['react', 'util', 'async_hooks', 'stream', 'react-dom'], }, + { + bundleTypes: __EXPERIMENTAL__ ? [NODE_DEV, NODE_PROD] : [], + moduleType: RENDERER, + entry: 'react-dom/static.edge', + global: 'ReactDOMStatic', + minifyWithProdErrorCodes: true, + wrapWithModuleBoundaries: false, + externals: ['react', 'react-dom'], + }, /******* React DOM Fizz Server External Runtime *******/ { @@ -335,6 +357,15 @@ const bundles = [ wrapWithModuleBoundaries: false, externals: ['react', 'util', 'async_hooks', 'react-dom'], }, + { + bundleTypes: [NODE_DEV, NODE_PROD], + moduleType: RENDERER, + entry: 'react-server-dom-webpack/server.edge', + global: 'ReactServerDOMServer', + minifyWithProdErrorCodes: false, + wrapWithModuleBoundaries: false, + externals: ['react', 'util', 'async_hooks', 'react-dom'], + }, /******* React Server DOM Webpack Client *******/ { diff --git a/scripts/shared/inlinedHostConfigs.js b/scripts/shared/inlinedHostConfigs.js index 3f7a41021aa6e..9a4a37539ab9b 100644 --- a/scripts/shared/inlinedHostConfigs.js +++ b/scripts/shared/inlinedHostConfigs.js @@ -99,6 +99,41 @@ module.exports = [ isFlowTyped: true, isServerSupported: true, }, + { + shortName: 'dom-edge', + entryPoints: [ + 'react-dom', + 'react-dom/unstable_testing', + 'react-dom/src/server/ReactDOMFizzServerEdge.js', + 'react-dom/static.edge', + 'react-dom/server-rendering-stub', + 'react-dom/unstable_server-external-runtime', + 'react-server-dom-webpack/server.edge', + 'react-server-dom-webpack/client', + ], + paths: [ + 'react-dom', + 'react-dom-bindings', + 'react-dom/client', + 'react-dom/server.edge', + 'react-dom/static.edge', + 'react-dom/unstable_testing', + 'react-dom/src/server/ReactDOMFizzServerEdge.js', // react-dom/server.edge + 'react-dom/src/server/ReactDOMFizzStaticEdge.js', + 'react-server-dom-webpack', + 'react-server-dom-webpack/client', + 'react-server-dom-webpack/server.edge', + 'react-server-dom-webpack/src/ReactFlightDOMServerEdge.js', // react-server-dom-webpack/server.edge + 'react-client/src/ReactFlightClientStream.js', // We can only type check this in streaming configurations. + 'react-devtools', + 'react-devtools-core', + 'react-devtools-shell', + 'react-devtools-shared', + 'shared/ReactDOMSharedInternals', + ], + isFlowTyped: true, + isServerSupported: true, + }, { shortName: 'dom-legacy', entryPoints: [