From 9f540fcc51eae6fb6eab8d4ccba00cb0477a6b7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Thu, 19 Dec 2024 12:54:59 -0500 Subject: [PATCH] [Flight] Support streaming of decodeReply in Edge environments (#31852) We support streaming `multipart/form-data` in Node.js using Busboy since that's kind of the idiomatic ecosystem way for handling these stream there. There's not really anything idiomatic like that for Edge that's universal yet. This adds a version that's basically just `AsyncIterable.from(formData)`. It could also be a `ReadableStream` of those entries since those are also `AsyncIterable`. I imagine that in the future we might add one from a binary `ReadableStream` that does the parsing built-in. --- .../npm/server.edge.js | 1 + .../react-server-dom-parcel/server.edge.js | 1 + .../src/server/ReactFlightDOMServerEdge.js | 49 ++++++++++++++++++ .../server/react-flight-dom-server.edge.js | 1 + .../npm/server.edge.js | 1 + .../react-server-dom-turbopack/server.edge.js | 1 + .../src/server/ReactFlightDOMServerEdge.js | 51 +++++++++++++++++++ .../server/react-flight-dom-server.edge.js | 1 + .../npm/server.edge.js | 1 + .../react-server-dom-webpack/server.edge.js | 1 + .../__tests__/ReactFlightDOMReplyEdge-test.js | 36 +++++++++++++ .../src/server/ReactFlightDOMServerEdge.js | 51 +++++++++++++++++++ .../server/react-flight-dom-server.edge.js | 1 + 13 files changed, 196 insertions(+) diff --git a/packages/react-server-dom-parcel/npm/server.edge.js b/packages/react-server-dom-parcel/npm/server.edge.js index 5f13279f75..356cce93a7 100644 --- a/packages/react-server-dom-parcel/npm/server.edge.js +++ b/packages/react-server-dom-parcel/npm/server.edge.js @@ -9,6 +9,7 @@ if (process.env.NODE_ENV === 'production') { exports.renderToReadableStream = s.renderToReadableStream; exports.decodeReply = s.decodeReply; +exports.decodeReplyFromAsyncIterable = s.decodeReplyFromAsyncIterable; exports.decodeAction = s.decodeAction; exports.decodeFormState = s.decodeFormState; exports.createClientReference = s.createClientReference; diff --git a/packages/react-server-dom-parcel/server.edge.js b/packages/react-server-dom-parcel/server.edge.js index 0974db3448..42f5c3d653 100644 --- a/packages/react-server-dom-parcel/server.edge.js +++ b/packages/react-server-dom-parcel/server.edge.js @@ -10,6 +10,7 @@ export { renderToReadableStream, decodeReply, + decodeReplyFromAsyncIterable, decodeAction, decodeFormState, createClientReference, diff --git a/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerEdge.js b/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerEdge.js index 73a8741618..2a365993a7 100644 --- a/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerEdge.js +++ b/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerEdge.js @@ -17,6 +17,8 @@ import { type ServerReferenceId, } from '../client/ReactFlightClientConfigBundlerParcel'; +import {ASYNC_ITERATOR} from 'shared/ReactSymbols'; + import { createRequest, createPrerenderRequest, @@ -30,6 +32,9 @@ import { createResponse, close, getRoot, + reportGlobalError, + resolveField, + resolveFile, } from 'react-server/src/ReactFlightReplyServer'; import { @@ -189,6 +194,50 @@ export function decodeReply( return root; } +export function decodeReplyFromAsyncIterable( + iterable: AsyncIterable<[string, string | File]>, + options?: {temporaryReferences?: TemporaryReferenceSet}, +): Thenable { + const iterator: AsyncIterator<[string, string | File]> = + iterable[ASYNC_ITERATOR](); + + const response = createResponse( + serverManifest, + '', + options ? options.temporaryReferences : undefined, + ); + + function progress( + entry: + | {done: false, +value: [string, string | File], ...} + | {done: true, +value: void, ...}, + ) { + if (entry.done) { + close(response); + } else { + const [name, value] = entry.value; + if (typeof value === 'string') { + resolveField(response, name, value); + } else { + resolveFile(response, name, value); + } + iterator.next().then(progress, error); + } + } + function error(reason: Error) { + reportGlobalError(response, reason); + if (typeof (iterator: any).throw === 'function') { + // The iterator protocol doesn't necessarily include this but a generator do. + // $FlowFixMe should be able to pass mixed + iterator.throw(reason).then(error, error); + } + } + + iterator.next().then(progress, error); + + return getRoot(response); +} + export function decodeAction(body: FormData): Promise<() => T> | null { return decodeActionImpl(body, serverManifest); } diff --git a/packages/react-server-dom-parcel/src/server/react-flight-dom-server.edge.js b/packages/react-server-dom-parcel/src/server/react-flight-dom-server.edge.js index c6b3067fbc..54f3dbb2ec 100644 --- a/packages/react-server-dom-parcel/src/server/react-flight-dom-server.edge.js +++ b/packages/react-server-dom-parcel/src/server/react-flight-dom-server.edge.js @@ -11,6 +11,7 @@ export { renderToReadableStream, prerender as unstable_prerender, decodeReply, + decodeReplyFromAsyncIterable, decodeAction, decodeFormState, createClientReference, diff --git a/packages/react-server-dom-turbopack/npm/server.edge.js b/packages/react-server-dom-turbopack/npm/server.edge.js index e34b18fa01..c832080079 100644 --- a/packages/react-server-dom-turbopack/npm/server.edge.js +++ b/packages/react-server-dom-turbopack/npm/server.edge.js @@ -9,6 +9,7 @@ if (process.env.NODE_ENV === 'production') { exports.renderToReadableStream = s.renderToReadableStream; exports.decodeReply = s.decodeReply; +exports.decodeReplyFromAsyncIterable = s.decodeReplyFromAsyncIterable; exports.decodeAction = s.decodeAction; exports.decodeFormState = s.decodeFormState; exports.registerServerReference = s.registerServerReference; diff --git a/packages/react-server-dom-turbopack/server.edge.js b/packages/react-server-dom-turbopack/server.edge.js index c527c7f76a..8f0347cd7b 100644 --- a/packages/react-server-dom-turbopack/server.edge.js +++ b/packages/react-server-dom-turbopack/server.edge.js @@ -10,6 +10,7 @@ export { renderToReadableStream, decodeReply, + decodeReplyFromAsyncIterable, decodeAction, decodeFormState, registerServerReference, diff --git a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerEdge.js b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerEdge.js index 11dbe1a7c1..e8256767fa 100644 --- a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerEdge.js +++ b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerEdge.js @@ -12,6 +12,8 @@ import type {Thenable} from 'shared/ReactTypes'; import type {ClientManifest} from './ReactFlightServerConfigTurbopackBundler'; import type {ServerManifest} from 'react-client/src/ReactFlightClientConfig'; +import {ASYNC_ITERATOR} from 'shared/ReactSymbols'; + import { createRequest, createPrerenderRequest, @@ -25,6 +27,9 @@ import { createResponse, close, getRoot, + reportGlobalError, + resolveField, + resolveFile, } from 'react-server/src/ReactFlightReplyServer'; import { @@ -183,10 +188,56 @@ function decodeReply( return root; } +function decodeReplyFromAsyncIterable( + iterable: AsyncIterable<[string, string | File]>, + turbopackMap: ServerManifest, + options?: {temporaryReferences?: TemporaryReferenceSet}, +): Thenable { + const iterator: AsyncIterator<[string, string | File]> = + iterable[ASYNC_ITERATOR](); + + const response = createResponse( + turbopackMap, + '', + options ? options.temporaryReferences : undefined, + ); + + function progress( + entry: + | {done: false, +value: [string, string | File], ...} + | {done: true, +value: void, ...}, + ) { + if (entry.done) { + close(response); + } else { + const [name, value] = entry.value; + if (typeof value === 'string') { + resolveField(response, name, value); + } else { + resolveFile(response, name, value); + } + iterator.next().then(progress, error); + } + } + function error(reason: Error) { + reportGlobalError(response, reason); + if (typeof (iterator: any).throw === 'function') { + // The iterator protocol doesn't necessarily include this but a generator do. + // $FlowFixMe should be able to pass mixed + iterator.throw(reason).then(error, error); + } + } + + iterator.next().then(progress, error); + + return getRoot(response); +} + export { renderToReadableStream, prerender, decodeReply, + decodeReplyFromAsyncIterable, decodeAction, decodeFormState, }; diff --git a/packages/react-server-dom-turbopack/src/server/react-flight-dom-server.edge.js b/packages/react-server-dom-turbopack/src/server/react-flight-dom-server.edge.js index 48c4fc4553..9198f9913e 100644 --- a/packages/react-server-dom-turbopack/src/server/react-flight-dom-server.edge.js +++ b/packages/react-server-dom-turbopack/src/server/react-flight-dom-server.edge.js @@ -11,6 +11,7 @@ export { renderToReadableStream, prerender as unstable_prerender, decodeReply, + decodeReplyFromAsyncIterable, decodeAction, decodeFormState, registerServerReference, diff --git a/packages/react-server-dom-webpack/npm/server.edge.js b/packages/react-server-dom-webpack/npm/server.edge.js index 591b844768..51a58ea7a9 100644 --- a/packages/react-server-dom-webpack/npm/server.edge.js +++ b/packages/react-server-dom-webpack/npm/server.edge.js @@ -9,6 +9,7 @@ if (process.env.NODE_ENV === 'production') { exports.renderToReadableStream = s.renderToReadableStream; exports.decodeReply = s.decodeReply; +exports.decodeReplyFromAsyncIterable = s.decodeReplyFromAsyncIterable; exports.decodeAction = s.decodeAction; exports.decodeFormState = s.decodeFormState; exports.registerServerReference = s.registerServerReference; diff --git a/packages/react-server-dom-webpack/server.edge.js b/packages/react-server-dom-webpack/server.edge.js index c527c7f76a..8f0347cd7b 100644 --- a/packages/react-server-dom-webpack/server.edge.js +++ b/packages/react-server-dom-webpack/server.edge.js @@ -10,6 +10,7 @@ export { renderToReadableStream, decodeReply, + decodeReplyFromAsyncIterable, decodeAction, decodeFormState, registerServerReference, diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReplyEdge-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReplyEdge-test.js index f6157dff17..2effa9868e 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReplyEdge-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReplyEdge-test.js @@ -272,4 +272,40 @@ describe('ReactFlightDOMReplyEdge', () => { expect(error).not.toBe(null); expect(error.message).toBe('Connection closed.'); }); + + it('can stream the decoding using an async iterable', async () => { + let resolve; + const promise = new Promise(r => (resolve = r)); + + const buffer = new Uint8Array([ + 123, 4, 10, 5, 100, 255, 244, 45, 56, 67, 43, 124, 67, 89, 100, 20, + ]); + + const formData = await ReactServerDOMClient.encodeReply({ + a: Promise.resolve('hello'), + b: Promise.resolve(buffer), + }); + + const iterable = { + async *[Symbol.asyncIterator]() { + // eslint-disable-next-line no-for-of-loops/no-for-of-loops + for (const entry of formData) { + yield entry; + await promise; + } + }, + }; + + const decoded = await ReactServerDOMServer.decodeReplyFromAsyncIterable( + iterable, + webpackServerMap, + ); + + expect(Object.keys(decoded)).toEqual(['a', 'b']); + + await resolve(); + + expect(await decoded.a).toBe('hello'); + expect(Array.from(await decoded.b)).toEqual(Array.from(buffer)); + }); }); diff --git a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerEdge.js b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerEdge.js index 7954417b95..e5b834be05 100644 --- a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerEdge.js +++ b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerEdge.js @@ -12,6 +12,8 @@ import type {Thenable} from 'shared/ReactTypes'; import type {ClientManifest} from './ReactFlightServerConfigWebpackBundler'; import type {ServerManifest} from 'react-client/src/ReactFlightClientConfig'; +import {ASYNC_ITERATOR} from 'shared/ReactSymbols'; + import { createRequest, createPrerenderRequest, @@ -25,6 +27,9 @@ import { createResponse, close, getRoot, + reportGlobalError, + resolveField, + resolveFile, } from 'react-server/src/ReactFlightReplyServer'; import { @@ -183,10 +188,56 @@ function decodeReply( return root; } +function decodeReplyFromAsyncIterable( + iterable: AsyncIterable<[string, string | File]>, + webpackMap: ServerManifest, + options?: {temporaryReferences?: TemporaryReferenceSet}, +): Thenable { + const iterator: AsyncIterator<[string, string | File]> = + iterable[ASYNC_ITERATOR](); + + const response = createResponse( + webpackMap, + '', + options ? options.temporaryReferences : undefined, + ); + + function progress( + entry: + | {done: false, +value: [string, string | File], ...} + | {done: true, +value: void, ...}, + ) { + if (entry.done) { + close(response); + } else { + const [name, value] = entry.value; + if (typeof value === 'string') { + resolveField(response, name, value); + } else { + resolveFile(response, name, value); + } + iterator.next().then(progress, error); + } + } + function error(reason: Error) { + reportGlobalError(response, reason); + if (typeof (iterator: any).throw === 'function') { + // The iterator protocol doesn't necessarily include this but a generator do. + // $FlowFixMe should be able to pass mixed + iterator.throw(reason).then(error, error); + } + } + + iterator.next().then(progress, error); + + return getRoot(response); +} + export { renderToReadableStream, prerender, decodeReply, + decodeReplyFromAsyncIterable, decodeAction, decodeFormState, }; diff --git a/packages/react-server-dom-webpack/src/server/react-flight-dom-server.edge.js b/packages/react-server-dom-webpack/src/server/react-flight-dom-server.edge.js index 48c4fc4553..9198f9913e 100644 --- a/packages/react-server-dom-webpack/src/server/react-flight-dom-server.edge.js +++ b/packages/react-server-dom-webpack/src/server/react-flight-dom-server.edge.js @@ -11,6 +11,7 @@ export { renderToReadableStream, prerender as unstable_prerender, decodeReply, + decodeReplyFromAsyncIterable, decodeAction, decodeFormState, registerServerReference,