diff --git a/.eslintrc.js b/.eslintrc.js index e0c61a608a47..913eec625cd4 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -589,6 +589,8 @@ module.exports = { CopyInspectedElementPath: 'readonly', DOMHighResTimeStamp: 'readonly', EventListener: 'readonly', + // Flow type + FormDataEntryValue: 'readonly', Iterable: 'readonly', AsyncIterable: 'readonly', $AsyncIterable: 'readonly', diff --git a/flow-typed/environments/bom.js b/flow-typed/environments/bom.js index 7c082ea5dae0..0e83e904cf76 100644 --- a/flow-typed/environments/bom.js +++ b/flow-typed/environments/bom.js @@ -682,11 +682,11 @@ declare class FormData { get(name: string): ?FormDataEntryValue; getAll(name: string): Array; - set(name: string, value: string): void; + set(name: string, value: FormDataEntryValue): void; set(name: string, value: Blob, filename?: string): void; set(name: string, value: File, filename?: string): void; - append(name: string, value: string): void; + append(name: string, value: FormDataEntryValue): void; append(name: string, value: Blob, filename?: string): void; append(name: string, value: File, filename?: string): void; diff --git a/package.json b/package.json index a1f5aa0eeff9..b37aa0bc0d39 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "art": "0.10.1", "babel-plugin-syntax-hermes-parser": "^0.32.0", "babel-plugin-syntax-trailing-function-commas": "^6.5.0", + "busboy": "^1.6.0", "chalk": "^3.0.0", "cli-table": "^0.3.1", "coffee-script": "^1.12.7", diff --git a/packages/react-client/src/ReactFlightReplyClient.js b/packages/react-client/src/ReactFlightReplyClient.js index 0661f7824650..bc6c83909e3e 100644 --- a/packages/react-client/src/ReactFlightReplyClient.js +++ b/packages/react-client/src/ReactFlightReplyClient.js @@ -590,7 +590,9 @@ export function processReply( // Copy all the form fields with a prefix for this reference. // These must come first in the form order because we assume that all the // fields are available before this is referenced. - const prefix = formFieldPrefix + refId + '_'; + // We include a special marker so that the Server can detect FormData entries + // that are values in referenced FormData objects. + const prefix = formFieldPrefix + '_' + refId + '_'; // $FlowFixMe[prop-missing]: FormData has forEach. value.forEach((originalValue: string | File, originalKey: string) => { // $FlowFixMe[incompatible-call] diff --git a/packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js index 8e0799fb0209..fb575aeffc9e 100644 --- a/packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js @@ -62,6 +62,7 @@ import { } from 'react-client/src/ReactFlightClientStreamConfigNode'; import type {TemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; +import type {FileHandle} from 'react-server/src/ReactFlightReplyServer'; export {createTemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; @@ -325,6 +326,17 @@ function prerenderToNodeStream( }); } +type PendingFile = { + name: string, + file: FileHandle, + complete: boolean, + // Lazily allocated when a text field arrives after this file's 'file' + // event but before its (deferred) 'end' event. Stored as flat + // [name1, value1, name2, value2, ...] pairs. + queuedFields: null | Array, + next: null | PendingFile, +}; + function decodeReplyFromBusboy( busboyStream: Busboy, moduleBasePath: ServerManifest, @@ -340,14 +352,55 @@ function decodeReplyFromBusboy( undefined, options ? options.arraySizeLimit : undefined, ); - let pendingFiles = 0; - const queuedFields: Array = []; + + // Linked list of pending files in arrival (payload) order. Text fields that + // arrive while a file is in flight are queued on the tail file's + // `queuedFields` so they can be resolved together when that file completes. + // Fields that arrive while the list is empty bypass it and resolve + // immediately. This makes the backing FormData's insertion order match the + // payload's entry order. + let head: null | PendingFile = null; + let tail: null | PendingFile = null; + let bodyFinished = false; + let closed = false; + + function flush() { + while (head !== null) { + const current = head; + if (!current.complete) { + // This file is still streaming. Hold later files and fields until it + // completes so the backing FormData reflects payload order. + return; + } + try { + resolveFileComplete(response, current.name, current.file); + const queuedFields = current.queuedFields; + if (queuedFields !== null) { + for (let i = 0; i < queuedFields.length; i += 2) { + resolveField(response, queuedFields[i], queuedFields[i + 1]); + } + } + } catch (error) { + busboyStream.destroy(error); + return; + } + head = current.next; + } + tail = null; + if (bodyFinished && !closed) { + closed = true; + close(response); + } + } + 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); + if (tail !== null) { + // A file is in flight; queue the field on the tail (most recent) pending + // file so it resolves after that file, preserving payload order. + if (tail.queuedFields === null) { + tail.queuedFields = []; + } + tail.queuedFields.push(name, value); } else { try { resolveField(response, name, value); @@ -367,29 +420,46 @@ function decodeReplyFromBusboy( ); return; } - pendingFiles++; const file = resolveFileInfo(response, name, filename, mimeType); + const pendingFile: PendingFile = { + name, + file, + complete: false, + queuedFields: null, + next: null, + }; + if (tail === null) { + head = pendingFile; + } else { + tail.next = pendingFile; + } + tail = pendingFile; 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; - } + resolveFileChunk(response, file, chunk); } catch (error) { busboyStream.destroy(error); } }); + value.on('error', error => { + busboyStream.destroy(error); + }); + value.on('end', () => { + pendingFile.complete = true; + flush(); + }); }); busboyStream.on('finish', () => { - close(response); + bodyFinished = true; + flush(); + if (!closed) { + // Invariant: busboy delays 'finish' until every file's 'end' event has + // fired, so the flush above should always close the response. + reportGlobalError( + response, + new Error('Reply finished with incomplete file part.'), + ); + } }); busboyStream.on('error', err => { reportGlobalError( diff --git a/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerEdge.js b/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerEdge.js index 83150996ae69..dba7bcfc51cb 100644 --- a/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerEdge.js +++ b/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerEdge.js @@ -20,6 +20,7 @@ import { type ServerReferenceId, } from '../client/ReactFlightClientConfigBundlerParcel'; +import noop from 'shared/noop'; import {ASYNC_ITERATOR} from 'shared/ReactSymbols'; import { @@ -306,8 +307,8 @@ export function decodeReplyFromAsyncIterable( 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); + // $FlowFixMe[prop-missing] should be able to pass mixed + iterator.throw(reason).then(noop, noop); } } diff --git a/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js index c5903c41ed49..8c2eab4c77b1 100644 --- a/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js @@ -24,6 +24,7 @@ import type {Duplex} from 'stream'; import {Readable} from 'stream'; +import noop from 'shared/noop'; import {ASYNC_ITERATOR} from 'shared/ReactSymbols'; import { @@ -75,6 +76,7 @@ import { import {textEncoder} from 'react-server/src/ReactServerStreamConfigNode'; import type {TemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; +import type {FileHandle} from 'react-server/src/ReactFlightReplyServer'; export {createTemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; @@ -554,6 +556,17 @@ export function registerServerActions(manifest: ServerManifest) { serverManifest = manifest; } +type PendingFile = { + name: string, + file: FileHandle, + complete: boolean, + // Lazily allocated when a text field arrives after this file's 'file' + // event but before its (deferred) 'end' event. Stored as flat + // [name1, value1, name2, value2, ...] pairs. + queuedFields: null | Array, + next: null | PendingFile, +}; + export function decodeReplyFromBusboy( busboyStream: Busboy, options?: { @@ -568,14 +581,55 @@ export function decodeReplyFromBusboy( undefined, options ? options.arraySizeLimit : undefined, ); - let pendingFiles = 0; - const queuedFields: Array = []; + + // Linked list of pending files in arrival (payload) order. Text fields that + // arrive while a file is in flight are queued on the tail file's + // `queuedFields` so they can be resolved together when that file completes. + // Fields that arrive while the list is empty bypass it and resolve + // immediately. This makes the backing FormData's insertion order match the + // payload's entry order. + let head: null | PendingFile = null; + let tail: null | PendingFile = null; + let bodyFinished = false; + let closed = false; + + function flush() { + while (head !== null) { + const current = head; + if (!current.complete) { + // This file is still streaming. Hold later files and fields until it + // completes so the backing FormData reflects payload order. + return; + } + try { + resolveFileComplete(response, current.name, current.file); + const queuedFields = current.queuedFields; + if (queuedFields !== null) { + for (let i = 0; i < queuedFields.length; i += 2) { + resolveField(response, queuedFields[i], queuedFields[i + 1]); + } + } + } catch (error) { + busboyStream.destroy(error); + return; + } + head = current.next; + } + tail = null; + if (bodyFinished && !closed) { + closed = true; + close(response); + } + } + 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); + if (tail !== null) { + // A file is in flight; queue the field on the tail (most recent) pending + // file so it resolves after that file, preserving payload order. + if (tail.queuedFields === null) { + tail.queuedFields = []; + } + tail.queuedFields.push(name, value); } else { try { resolveField(response, name, value); @@ -595,29 +649,46 @@ export function decodeReplyFromBusboy( ); return; } - pendingFiles++; const file = resolveFileInfo(response, name, filename, mimeType); + const pendingFile: PendingFile = { + name, + file, + complete: false, + queuedFields: null, + next: null, + }; + if (tail === null) { + head = pendingFile; + } else { + tail.next = pendingFile; + } + tail = pendingFile; 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; - } + resolveFileChunk(response, file, chunk); } catch (error) { busboyStream.destroy(error); } }); + value.on('error', error => { + busboyStream.destroy(error); + }); + value.on('end', () => { + pendingFile.complete = true; + flush(); + }); }); busboyStream.on('finish', () => { - close(response); + bodyFinished = true; + flush(); + if (!closed) { + // Invariant: busboy delays 'finish' until every file's 'end' event has + // fired, so the flush above should always close the response. + reportGlobalError( + response, + new Error('Reply finished with incomplete file part.'), + ); + } }); busboyStream.on('error', err => { reportGlobalError( @@ -692,8 +763,8 @@ export function decodeReplyFromAsyncIterable( 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); + // $FlowFixMe[prop-missing] should be able to pass mixed + iterator.throw(reason).then(noop, noop); } } diff --git a/packages/react-server-dom-rspack/src/__tests__/ReactFlightDOMRspackReplyNode-test.js b/packages/react-server-dom-rspack/src/__tests__/ReactFlightDOMRspackReplyNode-test.js new file mode 100644 index 000000000000..9e4d71bdda0f --- /dev/null +++ b/packages/react-server-dom-rspack/src/__tests__/ReactFlightDOMRspackReplyNode-test.js @@ -0,0 +1,164 @@ +/** + * 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. + * + * @emails react-core + * @jest-environment node + */ + +'use strict'; + +const {installRspackTestManifest} = require('./utils/RspackTestManifest'); + +let busboy; +let ReactServerDOMServer; +let ReactServerDOMClient; +let rspack; + +describe('ReactFlightDOMRspackReplyNode', () => { + beforeEach(() => { + jest.resetModules(); + jest.mock('react', () => require('react/react.react-server')); + rspack = installRspackTestManifest(); + ReactServerDOMServer = require('react-server-dom-rspack/server.node'); + + jest.resetModules(); + __unmockReact(); + ReactServerDOMClient = require('react-server-dom-rspack/client.browser'); + busboy = require('busboy'); + }); + + afterEach(() => { + rspack.restore(); + }); + + async function pipeBodyToBusboy(bb, body, boundary) { + // eslint-disable-next-line no-for-of-loops/no-for-of-loops + for (const [name, value] of body) { + if (typeof value === 'string') { + bb.write( + `--${boundary}\r\n` + + `Content-Disposition: form-data; name="${name}"\r\n` + + `\r\n` + + `${value}\r\n`, + ); + } else { + const filename = + typeof value.name === 'string' && value.name !== '' + ? value.name + : 'blob'; + const mimeType = + typeof value.type === 'string' && value.type !== '' + ? value.type + : 'application/octet-stream'; + const buffer = Buffer.from(await value.arrayBuffer()); + bb.write( + `--${boundary}\r\n` + + `Content-Disposition: form-data; name="${name}"; filename="${filename}"\r\n` + + `Content-Type: ${mimeType}\r\n` + + `\r\n`, + ); + bb.write(buffer); + bb.write('\r\n'); + } + } + bb.end(`--${boundary}--\r\n`); + } + + function createBusboy(boundary) { + return busboy({ + headers: { + 'content-type': `multipart/form-data; boundary=${boundary}`, + }, + }); + } + + it('preserves interleaved file and field order in referenced FormData', async () => { + const first = new FormData(); + first.append( + 'first-file', + new Blob(['first-content'], {type: 'text/plain'}), + 'first.txt', + ); + first.append('first-text', 'first-value'); + const second = new FormData(); + second.append( + 'second-file', + new Blob(['second-content'], {type: 'text/plain'}), + 'second.txt', + ); + second.append('second-text', 'second-value'); + + const body = await ReactServerDOMClient.encodeReply([first, second]); + const boundary = 'rspack-reply-boundary'; + const bb = createBusboy(boundary); + const reply = ReactServerDOMServer.decodeReplyFromBusboy(bb); + await pipeBodyToBusboy(bb, body, boundary); + + const [decodedFirst, decodedSecond] = await reply; + expect(Array.from(decodedFirst.keys())).toEqual([ + 'first-file', + 'first-text', + ]); + expect(decodedFirst.get('first-text')).toBe('first-value'); + expect(decodedFirst.get('first-file').name).toBe('first.txt'); + expect(Array.from(decodedSecond.keys())).toEqual([ + 'second-file', + 'second-text', + ]); + expect(decodedSecond.get('second-text')).toBe('second-value'); + expect(decodedSecond.get('second-file').name).toBe('second.txt'); + }); + + it('preserves text before file order across referenced FormDatas', async () => { + const first = new FormData(); + first.append('first-text', 'first-value'); + first.append( + 'first-file', + new Blob(['first-content'], {type: 'text/plain'}), + 'first.txt', + ); + const second = new FormData(); + second.append('second-text', 'second-value'); + second.append( + 'second-file', + new Blob(['second-content'], {type: 'text/plain'}), + 'second.txt', + ); + + const body = await ReactServerDOMClient.encodeReply([first, second]); + const boundary = 'rspack-text-file-boundary'; + const bb = createBusboy(boundary); + const reply = ReactServerDOMServer.decodeReplyFromBusboy(bb); + await pipeBodyToBusboy(bb, body, boundary); + + const [decodedFirst, decodedSecond] = await reply; + expect(Array.from(decodedFirst.keys())).toEqual([ + 'first-text', + 'first-file', + ]); + expect(decodedFirst.get('first-text')).toBe('first-value'); + expect(decodedFirst.get('first-file').name).toBe('first.txt'); + expect(Array.from(decodedSecond.keys())).toEqual([ + 'second-text', + 'second-file', + ]); + expect(decodedSecond.get('second-text')).toBe('second-value'); + expect(decodedSecond.get('second-file').name).toBe('second.txt'); + }); + + it('enforces the array size limit for Busboy replies', async () => { + const body = new FormData(); + body.set('0', JSON.stringify([[0, 1]])); + const boundary = 'rspack-array-limit-boundary'; + const bb = createBusboy(boundary); + const reply = ReactServerDOMServer.decodeReplyFromBusboy(bb, { + arraySizeLimit: 3, + }); + await pipeBodyToBusboy(bb, body, boundary); + + await expect(reply).rejects.toThrow('Maximum array nesting exceeded'); + }); +}); diff --git a/packages/react-server-dom-rspack/src/__tests__/ReactFlightRspackReply-test.js b/packages/react-server-dom-rspack/src/__tests__/ReactFlightRspackReply-test.js new file mode 100644 index 000000000000..d717670a3c8f --- /dev/null +++ b/packages/react-server-dom-rspack/src/__tests__/ReactFlightRspackReply-test.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. + * + * @emails react-core + */ + +'use strict'; + +const { + describeRspackReplySecurityTests, +} = require('./utils/RspackReplySecurityTests'); + +describeRspackReplySecurityTests( + () => require('react-server-dom-rspack/server.node'), + () => require('react-server-dom-rspack/client.browser'), +); diff --git a/packages/react-server-dom-rspack/src/__tests__/ReactFlightRspackReplyBrowser-test.js b/packages/react-server-dom-rspack/src/__tests__/ReactFlightRspackReplyBrowser-test.js new file mode 100644 index 000000000000..f80f823cbbc1 --- /dev/null +++ b/packages/react-server-dom-rspack/src/__tests__/ReactFlightRspackReplyBrowser-test.js @@ -0,0 +1,27 @@ +/** + * 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. + * + * @emails react-core + * @jest-environment ./scripts/jest/ReactDOMServerIntegrationEnvironment + */ + +'use strict'; + +const { + describeRspackReplySecurityTests, +} = require('./utils/RspackReplySecurityTests'); +const { + patchMessageChannel, +} = require('../../../../scripts/jest/patchMessageChannel'); + +describeRspackReplySecurityTests( + () => { + patchMessageChannel(); + return require('react-server-dom-rspack/server.browser'); + }, + () => require('react-server-dom-rspack/client.browser'), + false, +); diff --git a/packages/react-server-dom-rspack/src/__tests__/ReactFlightRspackReplyEdge-test.js b/packages/react-server-dom-rspack/src/__tests__/ReactFlightRspackReplyEdge-test.js new file mode 100644 index 000000000000..aa20d474cfbf --- /dev/null +++ b/packages/react-server-dom-rspack/src/__tests__/ReactFlightRspackReplyEdge-test.js @@ -0,0 +1,20 @@ +/** + * 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. + * + * @emails react-core + * @jest-environment ./scripts/jest/ReactDOMServerIntegrationEnvironment + */ + +'use strict'; + +const { + describeRspackReplySecurityTests, +} = require('./utils/RspackReplySecurityTests'); + +describeRspackReplySecurityTests( + () => require('react-server-dom-rspack/server.edge'), + () => require('react-server-dom-rspack/client.edge'), +); diff --git a/packages/react-server-dom-rspack/src/__tests__/utils/RspackReplySecurityTests.js b/packages/react-server-dom-rspack/src/__tests__/utils/RspackReplySecurityTests.js new file mode 100644 index 000000000000..f2040c78f457 --- /dev/null +++ b/packages/react-server-dom-rspack/src/__tests__/utils/RspackReplySecurityTests.js @@ -0,0 +1,252 @@ +/** + * 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. + */ + +'use strict'; + +const {installRspackTestManifest} = require('./RspackTestManifest'); + +function describeRspackReplySecurityTests( + loadServer, + loadClient, + supportsAsyncIterable = true, +) { + let ReactServerDOMServer; + let ReactServerDOMClient; + let rspack; + + describe('ReactFlightRspackReply security behavior', () => { + beforeEach(() => { + jest.resetModules(); + jest.mock('react', () => require('react/react.react-server')); + rspack = installRspackTestManifest(); + ReactServerDOMServer = loadServer(); + + jest.resetModules(); + __unmockReact(); + ReactServerDOMClient = loadClient(); + }); + + afterEach(() => { + rspack.restore(); + }); + + it('uses only the last action when a submitter overrides the form action', async () => { + const body = new FormData(); + body.append('message', 'hello'); + for (let i = 0; i < 7; i++) { + const actionId = 'overridden-' + i; + rspack.registerErroredServerAction( + actionId, + new Error('An overridden action must not be loaded.'), + ); + body.append('$ACTION_ID_' + actionId, ''); + } + rspack.registerServerAction('submitter', formData => { + return 'submitter:' + formData.get('message'); + }); + body.append('$ACTION_ID_submitter', ''); + + const action = await ReactServerDOMServer.decodeAction(body); + + expect(await action()).toBe('submitter:hello'); + expect(rspack.getModuleLoadCount()).toBe(1); + }); + + it('lazily decodes only the last bound action and form state metadata', async () => { + rspack.registerServerAction('submitter', (state, formData) => { + return `${state}:${formData.get('message')}`; + }); + + const body = new FormData(); + body.append('message', 'hello'); + body.append('$ACTION_invalid:0', '{'); + body.append('$ACTION_REF_invalid', ''); + + const metadata = await ReactServerDOMClient.encodeReply({ + id: 'submitter', + bound: Promise.resolve(['initial-state']), + }); + if (typeof metadata === 'string') { + body.append('$ACTION_selected:0', metadata); + } else { + metadata.forEach((value, key) => { + body.append('$ACTION_selected:' + key, value); + }); + } + body.append('$ACTION_REF_selected', ''); + body.append('$ACTION_KEY', 'state-key'); + + const action = await ReactServerDOMServer.decodeAction(body); + expect(await action()).toBe('initial-state:hello'); + expect(rspack.getModuleLoadCount()).toBe(1); + await expect( + ReactServerDOMServer.decodeFormState('result', body), + ).resolves.toEqual(['result', 'state-key', 'submitter', 0]); + }); + + it('returns null synchronously when a form has no action metadata', () => { + const body = new FormData(); + body.append('message', 'hello'); + + expect(ReactServerDOMServer.decodeAction(body)).toBe(null); + }); + + it('rejects a Blob reference whose backing entry is a string', async () => { + const body = new FormData(); + body.set('1', '-'.repeat(50000)); + body.set('0', JSON.stringify(['$B1'])); + + let error; + try { + await ReactServerDOMServer.decodeReply(body); + } catch (x) { + error = x; + } + + expect(error).toEqual( + expect.objectContaining({ + message: expect.stringContaining('Referenced Blob is not a Blob.'), + }), + ); + }); + + it('round trips cyclic objects and arrays without duplicating them', async () => { + const cyclicObject = {self: null}; + cyclicObject.self = cyclicObject; + const cyclicArray = [cyclicObject]; + cyclicArray.push(cyclicArray); + + const body = await ReactServerDOMClient.encodeReply({ + cyclicObject, + cyclicArray, + }); + const result = await ReactServerDOMServer.decodeReply(body); + + expect(result.cyclicObject.self).toBe(result.cyclicObject); + expect(result.cyclicArray[0]).toBe(result.cyclicObject); + expect(result.cyclicArray[1]).toBe(result.cyclicArray); + }); + + it('marks collection models consumed before hostile iteration', async () => { + const nativeIterator = Array.prototype[Symbol.iterator]; + const iteratorSpy = jest + .spyOn(Array.prototype, Symbol.iterator) + .mockImplementation(function () { + const isMapModel = + this.length === 1 && + Array.isArray(this[0]) && + this[0][0] === 'map-marker'; + const isSetOrIteratorModel = + this.length === 2 && + (this[0] === 'set-marker' || this[0] === 'iterator-marker'); + if ( + (isMapModel || isSetOrIteratorModel) && + !Object.prototype.hasOwnProperty.call(this, '$$consumed') + ) { + throw new Error( + 'Collection model was consumed before it was marked.', + ); + } + return nativeIterator.call(this); + }); + + try { + const cases = [ + ['$Q1', '[["map-marker","$Q1"]]', 'Already initialized Map.'], + ['$W1', '["set-marker","$W1"]', 'Already initialized Set.'], + ['$i1', '["iterator-marker","$i1"]', 'Already initialized Iterator.'], + ]; + for (let i = 0; i < cases.length; i++) { + const [root, model, expectedError] = cases[i]; + const body = new FormData(); + body.append('0', JSON.stringify(root)); + body.append('1', model); + await expect(ReactServerDOMServer.decodeReply(body)).rejects.toThrow( + expectedError, + ); + } + } finally { + iteratorSpy.mockRestore(); + } + }); + + it('does not materialize an own __proto__ property from a reply', async () => { + const body = new FormData(); + body.set('0', '{"safe":true,"__proto__":{"polluted":true}}'); + + const result = await ReactServerDOMServer.decodeReply(body); + + expect(result).toEqual({safe: true}); + expect(Object.getPrototypeOf(result)).toBe(Object.prototype); + expect({}.polluted).toBe(undefined); + }); + + if (supportsAsyncIterable) { + it('throws once into a failed async iterator for either settlement', async () => { + await Promise.all( + [false, true].map(async throwRejects => { + const failure = new Error('input failed'); + let throwCount = 0; + const iterable = { + [Symbol.asyncIterator]() { + return { + next() { + return Promise.reject(failure); + }, + throw(reason) { + throwCount++; + if (throwCount > 1) { + return new Promise(() => {}); + } + return throwRejects + ? Promise.reject(reason) + : Promise.resolve(); + }, + }; + }, + }; + + await expect( + ReactServerDOMServer.decodeReplyFromAsyncIterable(iterable), + ).rejects.toThrow('input failed'); + await Promise.resolve(); + await Promise.resolve(); + + expect(throwCount).toBe(1); + }), + ); + }); + } + + it('enforces a caller-provided array size limit', async () => { + const body = new FormData(); + body.set('0', JSON.stringify([[0, 1]])); + + await expect( + ReactServerDOMServer.decodeReply(body, {arraySizeLimit: 3}), + ).rejects.toThrow('Maximum array nesting exceeded'); + }); + + if (supportsAsyncIterable) { + it('enforces the array size limit for async iterable replies', async () => { + const iterable = { + async *[Symbol.asyncIterator]() { + yield ['0', JSON.stringify([[0, 1]])]; + }, + }; + + await expect( + ReactServerDOMServer.decodeReplyFromAsyncIterable(iterable, { + arraySizeLimit: 3, + }), + ).rejects.toThrow('Maximum array nesting exceeded'); + }); + } + }); +} + +exports.describeRspackReplySecurityTests = describeRspackReplySecurityTests; diff --git a/packages/react-server-dom-rspack/src/__tests__/utils/RspackTestManifest.js b/packages/react-server-dom-rspack/src/__tests__/utils/RspackTestManifest.js new file mode 100644 index 000000000000..d4c43fbd7fcc --- /dev/null +++ b/packages/react-server-dom-rspack/src/__tests__/utils/RspackTestManifest.js @@ -0,0 +1,71 @@ +/** + * 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. + */ + +'use strict'; + +function installRspackTestManifest() { + const previousManifest = global.__rspack_rsc_manifest__; + const previousRequire = global.__webpack_require__; + const previousChunkLoad = global.__webpack_chunk_load__; + const modules = new Map(); + let moduleLoadCount = 0; + + global.__webpack_require__ = moduleId => { + moduleLoadCount++; + const moduleRecord = modules.get(moduleId); + if (moduleRecord.error !== undefined) { + throw moduleRecord.error; + } + return moduleRecord.exports; + }; + global.__webpack_chunk_load__ = () => Promise.resolve(); + global.__rspack_rsc_manifest__ = { + clientManifest: {}, + serverManifest: {}, + serverConsumerModuleMap: null, + moduleLoading: null, + entryJsFiles: [], + entryCssFiles: {}, + }; + + return { + getModuleLoadCount() { + return moduleLoadCount; + }, + + registerServerAction(actionId, action) { + const moduleId = 'server-action:' + actionId; + const exportName = 'action'; + modules.set(moduleId, { + exports: {[exportName]: action}, + }); + global.__rspack_rsc_manifest__.serverManifest[actionId] = { + id: moduleId, + chunks: [], + name: exportName, + }; + }, + + registerErroredServerAction(actionId, error) { + const moduleId = 'server-action:' + actionId; + modules.set(moduleId, {error}); + global.__rspack_rsc_manifest__.serverManifest[actionId] = { + id: moduleId, + chunks: [], + name: 'action', + }; + }, + + restore() { + global.__rspack_rsc_manifest__ = previousManifest; + global.__webpack_require__ = previousRequire; + global.__webpack_chunk_load__ = previousChunkLoad; + }, + }; +} + +exports.installRspackTestManifest = installRspackTestManifest; diff --git a/packages/react-server-dom-rspack/src/server/ReactFlightDOMServerBrowser.js b/packages/react-server-dom-rspack/src/server/ReactFlightDOMServerBrowser.js index 67a146117355..d32b859d2457 100644 --- a/packages/react-server-dom-rspack/src/server/ReactFlightDOMServerBrowser.js +++ b/packages/react-server-dom-rspack/src/server/ReactFlightDOMServerBrowser.js @@ -249,7 +249,10 @@ function prerender( function decodeReply( body: string | FormData, - options?: {temporaryReferences?: TemporaryReferenceSet}, + options?: { + temporaryReferences?: TemporaryReferenceSet, + arraySizeLimit?: number, + }, ): Thenable { if (typeof body === 'string') { const form = new FormData(); @@ -261,6 +264,7 @@ function decodeReply( '', options ? options.temporaryReferences : undefined, body, + options ? options.arraySizeLimit : undefined, ); const root = getRoot(response); close(response); diff --git a/packages/react-server-dom-rspack/src/server/ReactFlightDOMServerEdge.js b/packages/react-server-dom-rspack/src/server/ReactFlightDOMServerEdge.js index e0e08cfb3c2e..c1b813735aa8 100644 --- a/packages/react-server-dom-rspack/src/server/ReactFlightDOMServerEdge.js +++ b/packages/react-server-dom-rspack/src/server/ReactFlightDOMServerEdge.js @@ -24,6 +24,7 @@ import { } from './ReactFlightServerConfigRspackBundler'; import type {ServerManifest} from 'react-client/src/ReactFlightClientConfig'; +import noop from 'shared/noop'; import {ASYNC_ITERATOR} from 'shared/ReactSymbols'; import { @@ -254,7 +255,10 @@ function prerender( function decodeReply( body: string | FormData, - options?: {temporaryReferences?: TemporaryReferenceSet}, + options?: { + temporaryReferences?: TemporaryReferenceSet, + arraySizeLimit?: number, + }, ): Thenable { if (typeof body === 'string') { const form = new FormData(); @@ -266,6 +270,7 @@ function decodeReply( '', options ? options.temporaryReferences : undefined, body, + options ? options.arraySizeLimit : undefined, ); const root = getRoot(response); close(response); @@ -274,7 +279,10 @@ function decodeReply( function decodeReplyFromAsyncIterable( iterable: AsyncIterable<[string, string | File]>, - options?: {temporaryReferences?: TemporaryReferenceSet}, + options?: { + temporaryReferences?: TemporaryReferenceSet, + arraySizeLimit?: number, + }, ): Thenable { const iterator: AsyncIterator<[string, string | File]> = iterable[ASYNC_ITERATOR](); @@ -283,6 +291,8 @@ function decodeReplyFromAsyncIterable( __rspack_rsc_manifest__.serverManifest, '', options ? options.temporaryReferences : undefined, + undefined, + options ? options.arraySizeLimit : undefined, ); function progress( @@ -306,8 +316,8 @@ function decodeReplyFromAsyncIterable( 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); + // $FlowFixMe[prop-missing] should be able to pass mixed + iterator.throw(reason).then(noop, noop); } } diff --git a/packages/react-server-dom-rspack/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-rspack/src/server/ReactFlightDOMServerNode.js index f1a5c681998d..2deb239693fe 100644 --- a/packages/react-server-dom-rspack/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-rspack/src/server/ReactFlightDOMServerNode.js @@ -31,6 +31,7 @@ import type {Duplex} from 'stream'; import {Readable} from 'stream'; +import noop from 'shared/noop'; import {ASYNC_ITERATOR} from 'shared/ReactSymbols'; import { @@ -76,6 +77,7 @@ import { import {textEncoder} from 'react-server/src/ReactServerStreamConfigNode'; import type {TemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; +import type {FileHandle} from 'react-server/src/ReactFlightReplyServer'; export {createTemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; @@ -554,23 +556,80 @@ function prerender( }); } +type PendingFile = { + name: string, + file: FileHandle, + complete: boolean, + // Lazily allocated when a text field arrives after this file's 'file' + // event but before its (deferred) 'end' event. Stored as flat + // [name1, value1, name2, value2, ...] pairs. + queuedFields: null | Array, + next: null | PendingFile, +}; + function decodeReplyFromBusboy( busboyStream: Busboy, - options?: {temporaryReferences?: TemporaryReferenceSet}, + options?: { + temporaryReferences?: TemporaryReferenceSet, + arraySizeLimit?: number, + }, ): Thenable { const response = createResponse( __rspack_rsc_manifest__.serverManifest, '', options ? options.temporaryReferences : undefined, + undefined, + options ? options.arraySizeLimit : undefined, ); - let pendingFiles = 0; - const queuedFields: Array = []; + + // Linked list of pending files in arrival (payload) order. Text fields that + // arrive while a file is in flight are queued on the tail file's + // `queuedFields` so they can be resolved together when that file completes. + // Fields that arrive while the list is empty bypass it and resolve + // immediately. This makes the backing FormData's insertion order match the + // payload's entry order. + let head: null | PendingFile = null; + let tail: null | PendingFile = null; + let bodyFinished = false; + let closed = false; + + function flush() { + while (head !== null) { + const current = head; + if (!current.complete) { + // This file is still streaming. Hold later files and fields until it + // completes so the backing FormData reflects payload order. + return; + } + try { + resolveFileComplete(response, current.name, current.file); + const queuedFields = current.queuedFields; + if (queuedFields !== null) { + for (let i = 0; i < queuedFields.length; i += 2) { + resolveField(response, queuedFields[i], queuedFields[i + 1]); + } + } + } catch (error) { + busboyStream.destroy(error); + return; + } + head = current.next; + } + tail = null; + if (bodyFinished && !closed) { + closed = true; + close(response); + } + } + 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); + if (tail !== null) { + // A file is in flight; queue the field on the tail (most recent) pending + // file so it resolves after that file, preserving payload order. + if (tail.queuedFields === null) { + tail.queuedFields = []; + } + tail.queuedFields.push(name, value); } else { try { resolveField(response, name, value); @@ -590,29 +649,46 @@ function decodeReplyFromBusboy( ); return; } - pendingFiles++; const file = resolveFileInfo(response, name, filename, mimeType); + const pendingFile: PendingFile = { + name, + file, + complete: false, + queuedFields: null, + next: null, + }; + if (tail === null) { + head = pendingFile; + } else { + tail.next = pendingFile; + } + tail = pendingFile; 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; - } + resolveFileChunk(response, file, chunk); } catch (error) { busboyStream.destroy(error); } }); + value.on('error', error => { + busboyStream.destroy(error); + }); + value.on('end', () => { + pendingFile.complete = true; + flush(); + }); }); busboyStream.on('finish', () => { - close(response); + bodyFinished = true; + flush(); + if (!closed) { + // Invariant: busboy delays 'finish' until every file's 'end' event has + // fired, so the flush above should always close the response. + reportGlobalError( + response, + new Error('Reply finished with incomplete file part.'), + ); + } }); busboyStream.on('error', err => { reportGlobalError( @@ -626,7 +702,10 @@ function decodeReplyFromBusboy( function decodeReply( body: string | FormData, - options?: {temporaryReferences?: TemporaryReferenceSet}, + options?: { + temporaryReferences?: TemporaryReferenceSet, + arraySizeLimit?: number, + }, ): Thenable { if (typeof body === 'string') { const form = new FormData(); @@ -638,6 +717,7 @@ function decodeReply( '', options ? options.temporaryReferences : undefined, body, + options ? options.arraySizeLimit : undefined, ); const root = getRoot(response); close(response); @@ -646,7 +726,10 @@ function decodeReply( function decodeReplyFromAsyncIterable( iterable: AsyncIterable<[string, string | File]>, - options?: {temporaryReferences?: TemporaryReferenceSet}, + options?: { + temporaryReferences?: TemporaryReferenceSet, + arraySizeLimit?: number, + }, ): Thenable { const iterator: AsyncIterator<[string, string | File]> = iterable[ASYNC_ITERATOR](); @@ -655,6 +738,8 @@ function decodeReplyFromAsyncIterable( __rspack_rsc_manifest__.serverManifest, '', options ? options.temporaryReferences : undefined, + undefined, + options ? options.arraySizeLimit : undefined, ); function progress( @@ -678,8 +763,8 @@ function decodeReplyFromAsyncIterable( 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); + // $FlowFixMe[prop-missing] should be able to pass mixed + iterator.throw(reason).then(noop, noop); } } diff --git a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerEdge.js b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerEdge.js index f6a8fcc9abcd..6ced97ff73aa 100644 --- a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerEdge.js +++ b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerEdge.js @@ -15,6 +15,7 @@ import type {Thenable} from 'shared/ReactTypes'; import type {ClientManifest} from './ReactFlightServerConfigTurbopackBundler'; import type {ServerManifest} from 'react-client/src/ReactFlightClientConfig'; +import noop from 'shared/noop'; import {ASYNC_ITERATOR} from 'shared/ReactSymbols'; import { @@ -306,8 +307,8 @@ function decodeReplyFromAsyncIterable( 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); + // $FlowFixMe[prop-missing] should be able to pass mixed + iterator.throw(reason).then(noop, noop); } } diff --git a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js index 74d379f53a02..e6f56147f7ac 100644 --- a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js @@ -22,6 +22,7 @@ import type {Duplex} from 'stream'; import {Readable} from 'stream'; +import noop from 'shared/noop'; import {ASYNC_ITERATOR} from 'shared/ReactSymbols'; import { @@ -68,6 +69,7 @@ import { import {textEncoder} from 'react-server/src/ReactServerStreamConfigNode'; import type {TemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; +import type {FileHandle} from 'react-server/src/ReactFlightReplyServer'; export {createTemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; @@ -545,6 +547,17 @@ function prerender( }); } +type PendingFile = { + name: string, + file: FileHandle, + complete: boolean, + // Lazily allocated when a text field arrives after this file's 'file' + // event but before its (deferred) 'end' event. Stored as flat + // [name1, value1, name2, value2, ...] pairs. + queuedFields: null | Array, + next: null | PendingFile, +}; + function decodeReplyFromBusboy( busboyStream: Busboy, turbopackMap: ServerManifest, @@ -560,14 +573,55 @@ function decodeReplyFromBusboy( undefined, options ? options.arraySizeLimit : undefined, ); - let pendingFiles = 0; - const queuedFields: Array = []; + + // Linked list of pending files in arrival (payload) order. Text fields that + // arrive while a file is in flight are queued on the tail file's + // `queuedFields` so they can be resolved together when that file completes. + // Fields that arrive while the list is empty bypass it and resolve + // immediately. This makes the backing FormData's insertion order match the + // payload's entry order. + let head: null | PendingFile = null; + let tail: null | PendingFile = null; + let bodyFinished = false; + let closed = false; + + function flush() { + while (head !== null) { + const current = head; + if (!current.complete) { + // This file is still streaming. Hold later files and fields until it + // completes so the backing FormData reflects payload order. + return; + } + try { + resolveFileComplete(response, current.name, current.file); + const queuedFields = current.queuedFields; + if (queuedFields !== null) { + for (let i = 0; i < queuedFields.length; i += 2) { + resolveField(response, queuedFields[i], queuedFields[i + 1]); + } + } + } catch (error) { + busboyStream.destroy(error); + return; + } + head = current.next; + } + tail = null; + if (bodyFinished && !closed) { + closed = true; + close(response); + } + } + 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); + if (tail !== null) { + // A file is in flight; queue the field on the tail (most recent) pending + // file so it resolves after that file, preserving payload order. + if (tail.queuedFields === null) { + tail.queuedFields = []; + } + tail.queuedFields.push(name, value); } else { try { resolveField(response, name, value); @@ -587,29 +641,46 @@ function decodeReplyFromBusboy( ); return; } - pendingFiles++; const file = resolveFileInfo(response, name, filename, mimeType); + const pendingFile: PendingFile = { + name, + file, + complete: false, + queuedFields: null, + next: null, + }; + if (tail === null) { + head = pendingFile; + } else { + tail.next = pendingFile; + } + tail = pendingFile; 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; - } + resolveFileChunk(response, file, chunk); } catch (error) { busboyStream.destroy(error); } }); + value.on('error', error => { + busboyStream.destroy(error); + }); + value.on('end', () => { + pendingFile.complete = true; + flush(); + }); }); busboyStream.on('finish', () => { - close(response); + bodyFinished = true; + flush(); + if (!closed) { + // Invariant: busboy delays 'finish' until every file's 'end' event has + // fired, so the flush above should always close the response. + reportGlobalError( + response, + new Error('Reply finished with incomplete file part.'), + ); + } }); busboyStream.on('error', err => { reportGlobalError( @@ -686,8 +757,8 @@ function decodeReplyFromAsyncIterable( 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); + // $FlowFixMe[prop-missing] should be able to pass mixed + iterator.throw(reason).then(noop, noop); } } diff --git a/packages/react-server-dom-unbundled/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-unbundled/src/server/ReactFlightDOMServerNode.js index 9a75c20395bc..ced4eb7d893f 100644 --- a/packages/react-server-dom-unbundled/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-unbundled/src/server/ReactFlightDOMServerNode.js @@ -22,6 +22,7 @@ import type {Duplex} from 'stream'; import {Readable} from 'stream'; +import noop from 'shared/noop'; import {ASYNC_ITERATOR} from 'shared/ReactSymbols'; import { @@ -68,6 +69,7 @@ import { import {textEncoder} from 'react-server/src/ReactServerStreamConfigNode'; import type {TemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; +import type {FileHandle} from 'react-server/src/ReactFlightReplyServer'; export {createTemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; @@ -545,6 +547,17 @@ function prerender( }); } +type PendingFile = { + name: string, + file: FileHandle, + complete: boolean, + // Lazily allocated when a text field arrives after this file's 'file' + // event but before its (deferred) 'end' event. Stored as flat + // [name1, value1, name2, value2, ...] pairs. + queuedFields: null | Array, + next: null | PendingFile, +}; + function decodeReplyFromBusboy( busboyStream: Busboy, webpackMap: ServerManifest, @@ -560,14 +573,55 @@ function decodeReplyFromBusboy( undefined, options ? options.arraySizeLimit : undefined, ); - let pendingFiles = 0; - const queuedFields: Array = []; + + // Linked list of pending files in arrival (payload) order. Text fields that + // arrive while a file is in flight are queued on the tail file's + // `queuedFields` so they can be resolved together when that file completes. + // Fields that arrive while the list is empty bypass it and resolve + // immediately. This makes the backing FormData's insertion order match the + // payload's entry order. + let head: null | PendingFile = null; + let tail: null | PendingFile = null; + let bodyFinished = false; + let closed = false; + + function flush() { + while (head !== null) { + const current = head; + if (!current.complete) { + // This file is still streaming. Hold later files and fields until it + // completes so the backing FormData reflects payload order. + return; + } + try { + resolveFileComplete(response, current.name, current.file); + const queuedFields = current.queuedFields; + if (queuedFields !== null) { + for (let i = 0; i < queuedFields.length; i += 2) { + resolveField(response, queuedFields[i], queuedFields[i + 1]); + } + } + } catch (error) { + busboyStream.destroy(error); + return; + } + head = current.next; + } + tail = null; + if (bodyFinished && !closed) { + closed = true; + close(response); + } + } + 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); + if (tail !== null) { + // A file is in flight; queue the field on the tail (most recent) pending + // file so it resolves after that file, preserving payload order. + if (tail.queuedFields === null) { + tail.queuedFields = []; + } + tail.queuedFields.push(name, value); } else { try { resolveField(response, name, value); @@ -587,29 +641,46 @@ function decodeReplyFromBusboy( ); return; } - pendingFiles++; const file = resolveFileInfo(response, name, filename, mimeType); + const pendingFile: PendingFile = { + name, + file, + complete: false, + queuedFields: null, + next: null, + }; + if (tail === null) { + head = pendingFile; + } else { + tail.next = pendingFile; + } + tail = pendingFile; 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; - } + resolveFileChunk(response, file, chunk); } catch (error) { busboyStream.destroy(error); } }); + value.on('error', error => { + busboyStream.destroy(error); + }); + value.on('end', () => { + pendingFile.complete = true; + flush(); + }); }); busboyStream.on('finish', () => { - close(response); + bodyFinished = true; + flush(); + if (!closed) { + // Invariant: busboy delays 'finish' until every file's 'end' event has + // fired, so the flush above should always close the response. + reportGlobalError( + response, + new Error('Reply finished with incomplete file part.'), + ); + } }); busboyStream.on('error', err => { reportGlobalError( @@ -686,8 +757,8 @@ function decodeReplyFromAsyncIterable( 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); + // $FlowFixMe[prop-missing] should be able to pass mixed + iterator.throw(reason).then(noop, noop); } } diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js index 409718973be9..9a16d179a635 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js @@ -676,4 +676,17 @@ describe('ReactFlightDOMReply', () => { // has closed but that's a bug in both ReactFlightReplyServer and ReactFlightClient. // It just halts in this case. }); + + it('cannot deserialize a Blob reference backed by a string', async () => { + const formData = new FormData(); + formData.set('1', '-'.repeat(50000)); + formData.set('0', JSON.stringify(['$B1'])); + let error; + try { + await ReactServerDOMServer.decodeReply(formData, webpackServerMap); + } catch (x) { + error = x; + } + expect(error.message).toContain('Referenced Blob is not a Blob.'); + }); }); diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReplyNode-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReplyNode-test.js new file mode 100644 index 000000000000..0924bafbdcd0 --- /dev/null +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReplyNode-test.js @@ -0,0 +1,149 @@ +/** + * 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. + * + * @emails react-core + * @jest-environment node + */ + +'use strict'; + +let webpackServerMap; +let busboy; +let ReactServerDOMServer; +let ReactServerDOMClient; + +describe('ReactFlightDOMReplyNode', () => { + beforeEach(() => { + jest.resetModules(); + // Simulate the condition resolution + jest.mock('react', () => require('react/react.react-server')); + jest.mock('react-server-dom-webpack/server', () => + require('react-server-dom-webpack/server.node'), + ); + const WebpackMock = require('./utils/WebpackMock'); + webpackServerMap = WebpackMock.webpackServerMap; + ReactServerDOMServer = require('react-server-dom-webpack/server.node'); + jest.resetModules(); + ReactServerDOMClient = require('react-server-dom-webpack/client.node'); + + busboy = require('busboy'); + }); + + // Writes the body to busboy as a multipart stream. Blob entries become + // `filename`-bearing parts so busboy emits them as 'file' events (with + // streamed data) rather than 'field' events. + async function pipeBodyToBusboy(bb, body, boundary) { + // eslint-disable-next-line no-for-of-loops/no-for-of-loops + for (const [name, value] of body) { + if (typeof value === 'string') { + bb.write( + `--${boundary}\r\n` + + `Content-Disposition: form-data; name="${name}"\r\n` + + `\r\n` + + `${value}\r\n`, + ); + } else { + const filename = + typeof value.name === 'string' && value.name !== '' + ? value.name + : 'blob'; + const mimeType = + typeof value.type === 'string' && value.type !== '' + ? value.type + : 'application/octet-stream'; + const buffer = Buffer.from(await value.arrayBuffer()); + bb.write( + `--${boundary}\r\n` + + `Content-Disposition: form-data; name="${name}"; filename="${filename}"\r\n` + + `Content-Type: ${mimeType}\r\n` + + `\r\n`, + ); + bb.write(buffer); + bb.write('\r\n'); + } + } + bb.end(`--${boundary}--\r\n`); + } + + // FormData iterates entries in insertion order per spec, so a referenced + // FormData must round-trip with its entry order intact even when files + // and text fields are interleaved in the payload. + it('preserves entry order when referenced FormDatas interleave files and text', async () => { + const a = new FormData(); + a.append('text_a', 'value_a'); + a.append('file_a', new Blob(['content_a'], {type: 'text/plain'}), 'a.txt'); + const b = new FormData(); + b.append('text_b', 'value_b'); + b.append('file_b', new Blob(['content_b'], {type: 'text/plain'}), 'b.txt'); + + const body = await ReactServerDOMClient.encodeReply([a, b]); + const boundary = 'boundary'; + const bb = busboy({ + headers: { + 'content-type': `multipart/form-data; boundary=${boundary}`, + }, + }); + const reply = ReactServerDOMServer.decodeReplyFromBusboy( + bb, + webpackServerMap, + ); + await pipeBodyToBusboy(bb, body, boundary); + + const result = await reply; + expect(result).toHaveLength(2); + const [decodedA, decodedB] = result; + + const aEntries = Array.from(decodedA.entries()); + expect(aEntries.map(([k]) => k)).toEqual(['text_a', 'file_a']); + expect(aEntries[0][1]).toBe('value_a'); + expect(aEntries[1][1]).toBeInstanceOf(File); + expect(aEntries[1][1].name).toBe('a.txt'); + + const bEntries = Array.from(decodedB.entries()); + expect(bEntries.map(([k]) => k)).toEqual(['text_b', 'file_b']); + expect(bEntries[0][1]).toBe('value_b'); + expect(bEntries[1][1]).toBeInstanceOf(File); + expect(bEntries[1][1].name).toBe('b.txt'); + }); + + // Every entry of a referenced FormData must be present in the decoded + // FormData regardless of where files appear in its iteration order. + it('does not drop entries when referenced FormDatas iterate files before text', async () => { + const a = new FormData(); + a.append('file_a', new Blob(['content_a'], {type: 'text/plain'}), 'a.txt'); + a.append('text_a', 'value_a'); + const b = new FormData(); + b.append('file_b', new Blob(['content_b'], {type: 'text/plain'}), 'b.txt'); + b.append('text_b', 'value_b'); + + const body = await ReactServerDOMClient.encodeReply([a, b]); + const boundary = 'boundary'; + const bb = busboy({ + headers: { + 'content-type': `multipart/form-data; boundary=${boundary}`, + }, + }); + const reply = ReactServerDOMServer.decodeReplyFromBusboy( + bb, + webpackServerMap, + ); + await pipeBodyToBusboy(bb, body, boundary); + + const result = await reply; + expect(result).toHaveLength(2); + const [decodedA, decodedB] = result; + + const aKeys = Array.from(decodedA.keys()).sort(); + expect(aKeys).toEqual(['file_a', 'text_a']); + expect(decodedA.get('text_a')).toBe('value_a'); + expect(decodedA.get('file_a')).toBeInstanceOf(File); + + const bKeys = Array.from(decodedB.keys()).sort(); + expect(bKeys).toEqual(['file_b', 'text_b']); + expect(decodedB.get('text_b')).toBe('value_b'); + expect(decodedB.get('file_b')).toBeInstanceOf(File); + }); +}); diff --git a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerEdge.js b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerEdge.js index 77067754bc59..323c1ee021ec 100644 --- a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerEdge.js +++ b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerEdge.js @@ -15,6 +15,7 @@ import type {Thenable} from 'shared/ReactTypes'; import type {ClientManifest} from './ReactFlightServerConfigWebpackBundler'; import type {ServerManifest} from 'react-client/src/ReactFlightClientConfig'; +import noop from 'shared/noop'; import {ASYNC_ITERATOR} from 'shared/ReactSymbols'; import { @@ -306,8 +307,8 @@ function decodeReplyFromAsyncIterable( 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); + // $FlowFixMe[prop-missing] should be able to pass mixed + iterator.throw(reason).then(noop, noop); } } diff --git a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js index 888d01391449..4004a1f7e65f 100644 --- a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js @@ -22,6 +22,7 @@ import type {Duplex} from 'stream'; import {Readable} from 'stream'; +import noop from 'shared/noop'; import {ASYNC_ITERATOR} from 'shared/ReactSymbols'; import { @@ -68,6 +69,7 @@ import { import {textEncoder} from 'react-server/src/ReactServerStreamConfigNode'; import type {TemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; +import type {FileHandle} from 'react-server/src/ReactFlightReplyServer'; export {createTemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; @@ -545,6 +547,17 @@ function prerender( }); } +type PendingFile = { + name: string, + file: FileHandle, + complete: boolean, + // Lazily allocated when a text field arrives after this file's 'file' + // event but before its (deferred) 'end' event. Stored as flat + // [name1, value1, name2, value2, ...] pairs. + queuedFields: null | Array, + next: null | PendingFile, +}; + function decodeReplyFromBusboy( busboyStream: Busboy, webpackMap: ServerManifest, @@ -560,14 +573,55 @@ function decodeReplyFromBusboy( undefined, options ? options.arraySizeLimit : undefined, ); - let pendingFiles = 0; - const queuedFields: Array = []; + + // Linked list of pending files in arrival (payload) order. Text fields that + // arrive while a file is in flight are queued on the tail file's + // `queuedFields` so they can be resolved together when that file completes. + // Fields that arrive while the list is empty bypass it and resolve + // immediately. This makes the backing FormData's insertion order match the + // payload's entry order. + let head: null | PendingFile = null; + let tail: null | PendingFile = null; + let bodyFinished = false; + let closed = false; + + function flush() { + while (head !== null) { + const current = head; + if (!current.complete) { + // This file is still streaming. Hold later files and fields until it + // completes so the backing FormData reflects payload order. + return; + } + try { + resolveFileComplete(response, current.name, current.file); + const queuedFields = current.queuedFields; + if (queuedFields !== null) { + for (let i = 0; i < queuedFields.length; i += 2) { + resolveField(response, queuedFields[i], queuedFields[i + 1]); + } + } + } catch (error) { + busboyStream.destroy(error); + return; + } + head = current.next; + } + tail = null; + if (bodyFinished && !closed) { + closed = true; + close(response); + } + } + 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); + if (tail !== null) { + // A file is in flight; queue the field on the tail (most recent) pending + // file so it resolves after that file, preserving payload order. + if (tail.queuedFields === null) { + tail.queuedFields = []; + } + tail.queuedFields.push(name, value); } else { try { resolveField(response, name, value); @@ -587,29 +641,46 @@ function decodeReplyFromBusboy( ); return; } - pendingFiles++; const file = resolveFileInfo(response, name, filename, mimeType); + const pendingFile: PendingFile = { + name, + file, + complete: false, + queuedFields: null, + next: null, + }; + if (tail === null) { + head = pendingFile; + } else { + tail.next = pendingFile; + } + tail = pendingFile; 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; - } + resolveFileChunk(response, file, chunk); } catch (error) { busboyStream.destroy(error); } }); + value.on('error', error => { + busboyStream.destroy(error); + }); + value.on('end', () => { + pendingFile.complete = true; + flush(); + }); }); busboyStream.on('finish', () => { - close(response); + bodyFinished = true; + flush(); + if (!closed) { + // Invariant: busboy delays 'finish' until every file's 'end' event has + // fired, so the flush above should always close the response. + reportGlobalError( + response, + new Error('Reply finished with incomplete file part.'), + ); + } }); busboyStream.on('error', err => { reportGlobalError( @@ -686,8 +757,8 @@ function decodeReplyFromAsyncIterable( 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); + // $FlowFixMe[prop-missing] should be able to pass mixed + iterator.throw(reason).then(noop, noop); } } diff --git a/packages/react-server/src/ReactFlightActionServer.js b/packages/react-server/src/ReactFlightActionServer.js index a3a47a9f9793..4f8f9c52ca53 100644 --- a/packages/react-server/src/ReactFlightActionServer.js +++ b/packages/react-server/src/ReactFlightActionServer.js @@ -112,53 +112,50 @@ export function decodeAction( // the implementation details of the action data. const formData = new FormData(); - let action: Promise<(formData: FormData) => T> | null = null; - const seenActions = new Set(); + let maybeActionKey: null | string = null; // $FlowFixMe[prop-missing] body.forEach((value: string | File, key: string) => { if (!key.startsWith('$ACTION_')) { // $FlowFixMe[incompatible-call] formData.append(key, value); - return; - } - // Later actions may override earlier actions if a button is used to - // override the default form action. However, we don't expect the same - // action ref field to be sent multiple times in legitimate form data. - if (key.startsWith('$ACTION_REF_')) { - if (seenActions.has(key)) { - return; - } - seenActions.add(key); - const formFieldPrefix = '$ACTION_' + key.slice(12) + ':'; - const metaData = decodeBoundActionMetaData( - body, - serverManifest, - formFieldPrefix, - ); - action = loadServerReference(serverManifest, metaData); - return; - } - // A simple action with no bound arguments may appear twice in the form data - // if a button specifies the same action as the default form action. We only - // load the first one, as they're guaranteed to be identical. - if (key.startsWith('$ACTION_ID_')) { - if (seenActions.has(key)) { - return; - } - seenActions.add(key); - const id = key.slice(11); - action = loadServerReference(serverManifest, { - id, - bound: null, - }); - return; + } else if (key.startsWith('$ACTION_REF_')) { + // Later actions may override earlier actions if a button is used to + // override the default form action. However, we don't expect the same + // action ref field to be sent multiple times in legitimate form data. + maybeActionKey = key; + } else if (key.startsWith('$ACTION_ID_')) { + // A simple action with no bound arguments may appear twice in the form data + // if a button specifies the same action as the default form action. + maybeActionKey = key; } }); - if (action === null) { + if (maybeActionKey === null) { return null; } + const actionKey = maybeActionKey; + + let action: Promise<(formData: FormData) => T> | null = null; + if (actionKey.startsWith('$ACTION_REF_')) { + const formFieldPrefix = + '$ACTION_' + actionKey.slice('$ACTION_REF_'.length) + ':'; + const metaData = decodeBoundActionMetaData( + body, + serverManifest, + formFieldPrefix, + ); + action = loadServerReference(serverManifest, metaData); + } else if (actionKey.startsWith('$ACTION_ID_')) { + const id = actionKey.slice('$ACTION_ID_'.length); + action = loadServerReference(serverManifest, { + id, + bound: null, + }); + } else { + throw new Error('Cannot handle action key. This is a bug in React.'); + } + // Return the action with the remaining FormData bound to the first argument. return action.then(fn => fn.bind(null, formData)); } @@ -175,24 +172,28 @@ export function decodeFormState( } // Search through the form data object to get the reference id and the number // of bound arguments. This repeats some of the work done in decodeAction. - let metaData = null; + let actionKey: null | string = null; // $FlowFixMe[prop-missing] body.forEach((value: string | File, key: string) => { if (key.startsWith('$ACTION_REF_')) { - const formFieldPrefix = '$ACTION_' + key.slice(12) + ':'; - metaData = decodeBoundActionMetaData( - body, - serverManifest, - formFieldPrefix, - ); + actionKey = key; } // We don't check for the simple $ACTION_ID_ case because form state actions // are always bound to the state argument. }); - if (metaData === null) { + if (actionKey === null) { // Should be unreachable. return Promise.resolve(null); } + + const formFieldPrefix = + '$ACTION_' + actionKey.slice('$ACTION_REF_'.length) + ':'; + const metaData = decodeBoundActionMetaData( + body, + serverManifest, + formFieldPrefix, + ); + const referenceId = metaData.id; return Promise.resolve(metaData.bound).then(bound => { if (bound === null) { diff --git a/packages/react-server/src/ReactFlightReplyBackingFormData.js b/packages/react-server/src/ReactFlightReplyBackingFormData.js new file mode 100644 index 000000000000..0fa250bafb2d --- /dev/null +++ b/packages/react-server/src/ReactFlightReplyBackingFormData.js @@ -0,0 +1,107 @@ +/** + * 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 + */ + +/** + * Backing FormData is a wrapper around FormData that allows iterating over the + * keys while allowing to evict values from the FormData without affecting the iteration. + * Native FormData.keys() will skip keys if entries with Blob are deleted e.g. + * ```js + * const formData = new FormData(); + * formData.append('a', new Blob()); + * formData.append('b', 2); + * const keys = formData.keys(); + * keys.next().value; // 'a' + * formData.delete('a'); + * keys.next().value; // undefined, but we expect 'b' + * ``` + */ +export opaque type BackingFormData = { + data: FormData, + keyPointer: number, + // Lazily initialized array of keys. We only need this at the moment + // for referenced FormData. + keys: null | Array, +}; + +export function peekBackingEntry(backingStore: BackingFormData): string | void { + let keys = backingStore.keys; + if (keys === null) { + keys = backingStore.keys = Array.from(backingStore.data.keys()); + backingStore.keyPointer = 0; + } + + return keys[backingStore.keyPointer]; +} + +export function advanceBackingEntryIterator( + backingStore: BackingFormData, +): void { + backingStore.keyPointer++; +} + +export function consumeBackingEntry( + backingStore: BackingFormData, + key: string, +): void { + backingStore.data.delete(key); + backingStore.keyPointer++; +} + +export function appendBackingEntry( + backingStore: BackingFormData, + key: string, + value: FormDataEntryValue, +): void { + backingStore.data.append(key, value); + let keys = backingStore.keys; + if (keys === null) { + keys = backingStore.keys = Array.from(backingStore.data.keys()); + backingStore.keyPointer = 0; + } else { + keys.push(key); + } +} + +export function appendBackingFile( + backingStore: BackingFormData, + key: string, + value: Blob, + filename: string, +): void { + backingStore.data.append(key, value, filename); + let keys = backingStore.keys; + if (keys === null) { + keys = backingStore.keys = Array.from(backingStore.data.keys()); + backingStore.keyPointer = 0; + } else { + keys.push(key); + } +} + +export function getBackingEntry( + backingStore: BackingFormData, + key: string, +): ?FormDataEntryValue { + return backingStore.data.get(key); +} + +export function getAllBackingEntries( + backingStore: BackingFormData, + key: string, +): Array { + return backingStore.data.getAll(key); +} + +export function createBackingFormData(formData: FormData): BackingFormData { + return { + data: formData, + keyPointer: -1, + keys: null, + }; +} diff --git a/packages/react-server/src/ReactFlightReplyServer.js b/packages/react-server/src/ReactFlightReplyServer.js index d3eff13ff465..1c79dbdeaef3 100644 --- a/packages/react-server/src/ReactFlightReplyServer.js +++ b/packages/react-server/src/ReactFlightReplyServer.js @@ -18,6 +18,7 @@ import type { ClientReference as ServerReference, } from 'react-client/src/ReactFlightClientConfig'; +import type {BackingFormData} from './ReactFlightReplyBackingFormData'; import type {TemporaryReferenceSet} from './ReactFlightServerTemporaryReferences'; import { @@ -26,6 +27,16 @@ import { requireModule, } from 'react-client/src/ReactFlightClientConfig'; +import { + createBackingFormData, + advanceBackingEntryIterator, + appendBackingEntry, + appendBackingFile, + consumeBackingEntry, + getBackingEntry, + getAllBackingEntries, + peekBackingEntry, +} from './ReactFlightReplyBackingFormData'; import { createTemporaryReference, registerTemporaryReference, @@ -192,7 +203,7 @@ const ArrayPrototype = Array.prototype; export type Response = { _bundlerConfig: ServerManifest, _prefix: string, - _formData: FormData, + _formData: BackingFormData, _chunks: Map>, _closed: boolean, _closedReason: mixed, @@ -605,7 +616,13 @@ function reviveModel( if (value.length > 1) { childContext.fork = true; } - bumpArrayCount(childContext, value.length + 1, response); + bumpArrayCount( + childContext, + // Number of commas + square brackets + // value.length - 1 + 2 + value.length + 1, + response, + ); for (let i = 0; i < value.length; i++) { const childRef = reference !== undefined ? reference + ':' + i : undefined; @@ -690,7 +707,9 @@ type InitializationReference = { type InitializationHandler = { chunk: null | BlockedChunk, value: any, - reason: any, + // TODO: Split type to make it impossible to treat a thrown value as NestedArrayContext. + // thrown value if errored, otherwise array context + reason: mixed | NestedArrayContext, deps: number, errored: boolean, }; @@ -785,11 +804,16 @@ export function reportGlobalError(response: Response, error: Error): void { // because we won't be getting any new data to resolve it. if (chunk.status === PENDING) { triggerErrorOnChunk(response, chunk, error); - } else if (chunk.status === INITIALIZED && chunk.reason !== null) { - const maybeController = chunk.reason; - // $FlowFixMe - if (typeof maybeController.error === 'function') { - maybeController.error(error); + } else if (chunk.status === INITIALIZED) { + const initializedChunk: + | InitializedChunk + | InitializedStreamChunk = (chunk: any); + if (initializedChunk.reason !== null) { + const maybeController = initializedChunk.reason; + // $FlowFixMe[method-unbinding] Just doing a typeof check + if (typeof maybeController.error === 'function') { + maybeController.error(error); + } } } }); @@ -802,7 +826,7 @@ function getChunk(response: Response, id: number): SomeChunk { const prefix = response._prefix; const key = prefix + id; // Check if we have this field in the backing store already. - const backingEntry = response._formData.get(key); + const backingEntry = getBackingEntry(response._formData, key); if (typeof backingEntry === 'string') { chunk = createResolvedModelChunk(response, backingEntry, id); } else if (response._closed) { @@ -923,7 +947,9 @@ function resolveReference( const initializedChunk: InitializedChunk = (chunk: any); initializedChunk.status = INITIALIZED; initializedChunk.value = handler.value; - initializedChunk.reason = handler.reason; // Used by streaming chunks + initializedChunk.reason = + // $FlowFixMe[incompatible-type] Assuming handler.errored is false. + handler.reason; if (resolveListeners !== null) { wakeChunk(response, resolveListeners, handler.value, initializedChunk); } @@ -1010,17 +1036,30 @@ function getOutlinedModel( ): T { const path = reference.split(':'); const id = parseInt(path[0], 16); - const chunk = getChunk(response, id); + let chunk = getChunk(response, id); switch (chunk.status) { case RESOLVED_MODEL: initializeModelChunk(chunk); + // $FlowFixMe[incompatible-cast] We just initialized this chunk so it can't be a ResolvedModelChunk anymore. + chunk = (chunk: Exclude, ResolvedModelChunk>); break; } // The status might have changed after initialization. switch (chunk.status) { case INITIALIZED: let value = chunk.value; - let arrayRoot: null | NestedArrayContext = chunk.reason; + const arrayRootOrController: + | null + | NestedArrayContext + | FlightStreamController = chunk.reason; + if (arrayRootOrController !== null && 'error' in arrayRootOrController) { + throw new Error( + 'Expected an initialized chunk but got an initialized stream chunk instead. ' + + 'This payload may have been submitted by an older version of React.', + ); + } + let arrayRoot = arrayRootOrController; + let localLength: number = 0; const rootArrayContexts = response._rootArrayContexts; for (let i = 1; i < path.length; i++) { @@ -1035,7 +1074,11 @@ function getOutlinedModel( value = value[name]; if (isArray(value)) { localLength = 0; - arrayRoot = rootArrayContexts.get(value) || arrayRoot; + arrayRoot = + rootArrayContexts.get( + // $FlowFixMe[incompatible-cast] Our `isArray` typing can't narrow `mixed` + (value: $ReadOnlyArray), + ) || arrayRoot; } else { arrayRoot = null; if (typeof value === 'string') { @@ -1122,8 +1165,9 @@ function createMap( if ((model as any).$$consumed === true) { throw new Error('Already initialized Map.'); } - const map = new Map(model); + // This needs to come first to prevent the model from being consumed again in case of a cyclic reference. (model as any).$$consumed = true; + const map = new Map(model); return map; } @@ -1134,8 +1178,9 @@ function createSet(response: Response, model: Array): Set { if ((model as any).$$consumed === true) { throw new Error('Already initialized Set.'); } - const set = new Set(model); + // This needs to come first to prevent the model from being consumed again in case of a cyclic reference. (model as any).$$consumed = true; + const set = new Set(model); return set; } @@ -1146,9 +1191,10 @@ function extractIterator(response: Response, model: Array): Iterator { if ((model as any).$$consumed === true) { throw new Error('Already initialized Iterator.'); } + // This needs to come first to prevent the model from being consumed again in case of a cyclic reference. + (model as any).$$consumed = true; // $FlowFixMe[incompatible-use]: This uses raw Symbols because we're extracting from a native array. const iterator = model[Symbol.iterator](); - (model as any).$$consumed = true; return iterator; } @@ -1191,7 +1237,7 @@ function parseTypedArray( // We should have this backingEntry in the store already because we emitted // it before referencing it. It should be a Blob. - const backingEntry: Blob = (response._formData.get(key): any); + const backingEntry: Blob = (getBackingEntry(response._formData, key): any); const promise: Promise = backingEntry.arrayBuffer(); @@ -1291,7 +1337,7 @@ function resolveStream>( const prefix = response._prefix; const key = prefix + id; - const existingEntries = response._formData.getAll(key); + const existingEntries = getAllBackingEntries(response._formData, key); for (let i = 0; i < existingEntries.length; i++) { const value = existingEntries[i]; if (typeof value === 'string') { @@ -1595,28 +1641,41 @@ function parseModelString( case 'K': { // FormData const stringId = value.slice(2); - const formPrefix = response._prefix + stringId + '_'; + + const responsePrefix = response._prefix; + // Use the special marker from the Client to distinguish keys that should + // be consumed by referenced FormData. + const anyFormPrefix = responsePrefix + '_'; + const formPrefix = anyFormPrefix + stringId + '_'; + const data = new FormData(); const backingFormData = response._formData; - // We assume that the reference to FormData always comes after each - // entry that it references so we can assume they all exist in the - // backing store already. - // Clone the keys to workaround bugs in the delete-while-iterating - // algorithm of FormData. - const keys = Array.from(backingFormData.keys()); - for (let i = 0; i < keys.length; i++) { - const entryKey = keys[i]; - if (entryKey.startsWith(formPrefix)) { - const entries = backingFormData.getAll(entryKey); - const newKey = entryKey.slice(formPrefix.length); - for (let j = 0; j < entries.length; j++) { + // We're still transpiling for-of loops, so we have to use the iterator directly instead of a for-of loop. + while (true) { + const formDataKey = peekBackingEntry(backingFormData); + if (formDataKey === undefined) { + break; + } + if (formDataKey.startsWith(formPrefix)) { + const referencedFormDataValue = getAllBackingEntries( + backingFormData, + formDataKey, + ); + const referencedFormDataKey = formDataKey.slice(formPrefix.length); + for (let i = 0; i < referencedFormDataValue.length; i++) { // $FlowFixMe[incompatible-call] - data.append(newKey, entries[j]); + data.append(referencedFormDataKey, referencedFormDataValue[i]); } - // These entries have now all been consumed. Let's free it. - // This also ensures that we don't have any entries left if we - // see the same key twice. - backingFormData.delete(entryKey); + consumeBackingEntry(backingFormData, formDataKey); + } else if (formDataKey.startsWith(anyFormPrefix)) { + // The FormData values are continuous and before the FormData reference. + // If we see something that doesn't look like a value for a referenced + // FormData, we can assume we're past the values for this FormData + // reference and stop iterating. + break; + } else { + // Either an outlined value or something not owned by this Reply. + advanceBackingEntryIterator(backingFormData); } } return data; @@ -1805,7 +1864,10 @@ function parseModelString( const blobKey = prefix + id; // We should have this backingEntry in the store already because we emitted // it before referencing it. It should be a Blob. - const backingEntry: Blob = (response._formData.get(blobKey): any); + const backingEntry = getBackingEntry(response._formData, blobKey); + if (!(backingEntry instanceof Blob)) { + throw new Error('Referenced Blob is not a Blob.'); + } return backingEntry; } case 'R': { @@ -1849,10 +1911,11 @@ export function createResponse( arraySizeLimit?: number = DEFAULT_MAX_ARRAY_NESTING, ): Response { const chunks: Map> = new Map(); + const response: Response = { _bundlerConfig: bundlerConfig, _prefix: formFieldPrefix, - _formData: backingFormData, + _formData: createBackingFormData(backingFormData), _chunks: chunks, _closed: false, _closedReason: null, @@ -1869,7 +1932,7 @@ export function resolveField( value: string, ): void { // Add this field to the backing store. - response._formData.append(key, value); + appendBackingEntry(response._formData, key, value); const prefix = response._prefix; if (key.startsWith(prefix)) { const chunks = response._chunks; @@ -1884,7 +1947,7 @@ export function resolveField( export function resolveFile(response: Response, key: string, file: File): void { // Add this field to the backing store. - response._formData.append(key, file); + appendBackingEntry(response._formData, key, file); } export opaque type FileHandle = { @@ -1924,7 +1987,7 @@ export function resolveFileComplete( // the append() form that takes the file name as the third argument, // to create a File object. const blob = new Blob(handle.chunks, {type: handle.mime}); - response._formData.append(key, blob, handle.filename); + appendBackingFile(response._formData, key, blob, handle.filename); } export function close(response: Response): void { diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index f31fa45f7a77..12c40e790d45 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -1355,8 +1355,8 @@ function serializeAsyncIterable( enqueueFlush(request); 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); + // $FlowFixMe[prop-missing] should be able to pass mixed + iterator.throw(reason).then(noop, noop); } } function abortIterable() { @@ -1376,9 +1376,11 @@ function serializeAsyncIterable( enqueueFlush(request); } if (typeof (iterator: any).throw === 'function') { + // TODO: Premature exits should call return() on the iterator if it exists + // to allow cleanup. See https://tc39.es/ecma262/multipage/control-abstraction-objects.html#table-async-iterator-optional // 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); + // $FlowFixMe[prop-missing] should be able to pass mixed + iterator.throw(reason).then(noop, noop); } } request.cacheController.signal.addEventListener('abort', abortIterable); diff --git a/packages/shared/ReactVersion.js b/packages/shared/ReactVersion.js index c7ea17f42b07..bd5fa23ca26b 100644 --- a/packages/shared/ReactVersion.js +++ b/packages/shared/ReactVersion.js @@ -1 +1,15 @@ -export default '19.3.0-canary-0d593e90-20260109'; +/** + * 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. + */ + +// TODO: this is special because it gets imported during build. +// +// It exists as a placeholder so that DevTools can support work tag changes between releases. +// When we next publish a release, update the matching TODO in backend/renderer.js +// TODO: This module is used both by the release scripts and to expose a version +// at runtime. We should instead inject the version number as part of the build +// process, and use the ReactVersions.js module as the single source of truth. +export default '19.3.0'; diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index 46bb2e6bccf0..69d88551322a 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -566,5 +566,8 @@ "578": "Already initialized Iterator.", "579": "Invalid data for bytes stream.", "580": "Server Function has too many bound arguments. Received %s but the limit is %s.", - "581": "BigInt is too large. Received %s digits but the limit is %s." + "581": "BigInt is too large. Received %s digits but the limit is %s.", + "582": "Referenced Blob is not a Blob.", + "599": "Expected an initialized chunk but got an initialized stream chunk instead. This payload may have been submitted by an older version of React.", + "602": "Cannot handle action key. This is a bug in React." } diff --git a/yarn.lock b/yarn.lock index c7f9e8501453..f80bae876bd3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6092,6 +6092,13 @@ bunyan@1.8.15: mv "~2" safe-json-stringify "~1" +busboy@^1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/busboy/-/busboy-1.6.0.tgz#966ea36a9502e43cdb9146962523b92f531f6893" + integrity sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA== + dependencies: + streamsearch "^1.1.0" + bytes@3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048" @@ -16093,6 +16100,11 @@ stream-shift@^1.0.0: resolved "https://registry.yarnpkg.com/stream-shift/-/stream-shift-1.0.1.tgz#d7088281559ab2778424279b0877da3c392d5a3d" integrity sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ== +streamsearch@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764" + integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg== + strict-uri-encode@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz#279b225df1d582b1f54e65addd4352e18faa0713"