diff --git a/package.json b/package.json index 7e9b35f00bee..00a3a5e67df6 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,7 @@ "through2": "^3.0.1", "tmp": "^0.1.0", "typescript": "^3.7.5", + "undici": "^5.28.4", "web-streams-polyfill": "^3.1.1", "yargs": "^15.3.1" }, diff --git a/packages/react-client/src/ReactFlightReplyClient.js b/packages/react-client/src/ReactFlightReplyClient.js index eb562dd7b23c..b54f6e4edb0f 100644 --- a/packages/react-client/src/ReactFlightReplyClient.js +++ b/packages/react-client/src/ReactFlightReplyClient.js @@ -187,9 +187,17 @@ export function processReply( function serializeTypedArray( tag: string, - typedArray: ArrayBuffer | $ArrayBufferView, + typedArray: $ArrayBufferView, ): string { - const blob = new Blob([typedArray]); + const blob = new Blob([ + // We should be able to pass the buffer straight through but Node < 18 treat + // multi-byte array blobs differently so we first convert it to single-byte. + new Uint8Array( + typedArray.buffer, + typedArray.byteOffset, + typedArray.byteLength, + ), + ]); const blobId = nextPartId++; if (formData === null) { formData = new FormData(); @@ -392,7 +400,13 @@ export function processReply( if (enableBinaryFlight) { if (value instanceof ArrayBuffer) { - return serializeTypedArray('A', value); + const blob = new Blob([value]); + const blobId = nextPartId++; + if (formData === null) { + formData = new FormData(); + } + formData.append(formFieldPrefix + blobId, blob); + return '$' + 'A' + blobId.toString(16); } if (value instanceof Int8Array) { // char diff --git a/packages/react-client/src/__tests__/ReactFlight-test.js b/packages/react-client/src/__tests__/ReactFlight-test.js index 7071ef508154..a2e4d7af2953 100644 --- a/packages/react-client/src/__tests__/ReactFlight-test.js +++ b/packages/react-client/src/__tests__/ReactFlight-test.js @@ -10,6 +10,14 @@ 'use strict'; +if (typeof Blob === 'undefined') { + global.Blob = require('buffer').Blob; +} +if (typeof File === 'undefined' || typeof FormData === 'undefined') { + global.File = require('undici').File; + global.FormData = require('undici').FormData; +} + function normalizeCodeLocInfo(str) { return ( str && @@ -513,39 +521,37 @@ describe('ReactFlight', () => { `); }); - if (typeof FormData !== 'undefined') { - it('can transport FormData (no blobs)', async () => { - function ComponentClient({prop}) { - return ` - formData: ${prop instanceof FormData} - hi: ${prop.get('hi')} - multiple: ${prop.getAll('multiple')} - content: ${JSON.stringify(Array.from(prop))} - `; - } - const Component = clientReference(ComponentClient); - - const formData = new FormData(); - formData.append('hi', 'world'); - formData.append('multiple', 1); - formData.append('multiple', 2); + it('can transport FormData (no blobs)', async () => { + function ComponentClient({prop}) { + return ` + formData: ${prop instanceof FormData} + hi: ${prop.get('hi')} + multiple: ${prop.getAll('multiple')} + content: ${JSON.stringify(Array.from(prop))} + `; + } + const Component = clientReference(ComponentClient); - const model = ; + const formData = new FormData(); + formData.append('hi', 'world'); + formData.append('multiple', 1); + formData.append('multiple', 2); - const transport = ReactNoopFlightServer.render(model); + const model = ; - await act(async () => { - ReactNoop.render(await ReactNoopFlightClient.read(transport)); - }); + const transport = ReactNoopFlightServer.render(model); - expect(ReactNoop).toMatchRenderedOutput(` - formData: true - hi: world - multiple: 1,2 - content: [["hi","world"],["multiple","1"],["multiple","2"]] - `); + await act(async () => { + ReactNoop.render(await ReactNoopFlightClient.read(transport)); }); - } + + expect(ReactNoop).toMatchRenderedOutput(` + formData: true + hi: world + multiple: 1,2 + content: [["hi","world"],["multiple","1"],["multiple","2"]] + `); + }); it('can transport cyclic objects', async () => { function ComponentClient({prop}) { diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js index 6e2e02047bbe..87a5266dee07 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js @@ -15,11 +15,10 @@ global.ReadableStream = require('web-streams-polyfill/ponyfill/es6').ReadableStream; global.TextEncoder = require('util').TextEncoder; global.TextDecoder = require('util').TextDecoder; -if (typeof Blob === 'undefined') { - global.Blob = require('buffer').Blob; -} -if (typeof File === 'undefined') { - global.File = require('buffer').File; +global.Blob = require('buffer').Blob; +if (typeof File === 'undefined' || typeof FormData === 'undefined') { + global.File = require('buffer').File || require('undici').File; + global.FormData = require('undici').FormData; } // Don't wait before processing work on the server. @@ -379,45 +378,40 @@ describe('ReactFlightDOMEdge', () => { expect(await result.arrayBuffer()).toEqual(await blob.arrayBuffer()); }); - if (typeof FormData !== 'undefined' && typeof File !== 'undefined') { - // @gate enableBinaryFlight - it('can transport FormData (blobs)', async () => { - const bytes = new Uint8Array([ - 123, 4, 10, 5, 100, 255, 244, 45, 56, 67, 43, 124, 67, 89, 100, 20, - ]); - const blob = new Blob([bytes, bytes], { - type: 'application/x-test', - }); - - const formData = new FormData(); - formData.append('hi', 'world'); - formData.append('file', blob, 'filename.test'); - - expect(formData.get('file') instanceof File).toBe(true); - expect(formData.get('file').name).toBe('filename.test'); - - const stream = passThrough( - ReactServerDOMServer.renderToReadableStream(formData), - ); - const result = await ReactServerDOMClient.createFromReadableStream( - stream, - { - ssrManifest: { - moduleMap: null, - moduleLoading: null, - }, - }, - ); - - expect(result instanceof FormData).toBe(true); - expect(result.get('hi')).toBe('world'); - const resultBlob = result.get('file'); - expect(resultBlob instanceof Blob).toBe(true); - expect(resultBlob.name).toBe('blob'); // We should not pass through the file name for security. - expect(resultBlob.size).toBe(bytes.length * 2); - expect(await resultBlob.arrayBuffer()).toEqual(await blob.arrayBuffer()); + // @gate enableBinaryFlight + it('can transport FormData (blobs)', async () => { + const bytes = new Uint8Array([ + 123, 4, 10, 5, 100, 255, 244, 45, 56, 67, 43, 124, 67, 89, 100, 20, + ]); + const blob = new Blob([bytes, bytes], { + type: 'application/x-test', }); - } + + const formData = new FormData(); + formData.append('hi', 'world'); + formData.append('file', blob, 'filename.test'); + + expect(formData.get('file') instanceof File).toBe(true); + expect(formData.get('file').name).toBe('filename.test'); + + const stream = passThrough( + ReactServerDOMServer.renderToReadableStream(formData), + ); + const result = await ReactServerDOMClient.createFromReadableStream(stream, { + ssrManifest: { + moduleMap: null, + moduleLoading: null, + }, + }); + + expect(result instanceof FormData).toBe(true); + expect(result.get('hi')).toBe('world'); + const resultBlob = result.get('file'); + expect(resultBlob instanceof Blob).toBe(true); + expect(resultBlob.name).toBe('blob'); // We should not pass through the file name for security. + expect(resultBlob.size).toBe(bytes.length * 2); + expect(await resultBlob.arrayBuffer()).toEqual(await blob.arrayBuffer()); + }); it('can pass an async import that resolves later to an outline object like a Map', async () => { let resolve; 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 00a53a590c5e..ab0d54c0bc0f 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReplyEdge-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReplyEdge-test.js @@ -16,11 +16,10 @@ global.ReadableStream = global.TextEncoder = require('util').TextEncoder; global.TextDecoder = require('util').TextDecoder; -if (typeof Blob === 'undefined') { - global.Blob = require('buffer').Blob; -} -if (typeof File === 'undefined') { - global.File = require('buffer').File; +global.Blob = require('buffer').Blob; +if (typeof File === 'undefined' || typeof FormData === 'undefined') { + global.File = require('buffer').File || require('undici').File; + global.FormData = require('undici').FormData; } // let serverExports; @@ -44,13 +43,6 @@ describe('ReactFlightDOMReplyEdge', () => { ReactServerDOMClient = require('react-server-dom-webpack/client.edge'); }); - if (typeof FormData === 'undefined') { - // We can't test if we don't have a native FormData implementation because the JSDOM one - // is missing the arrayBuffer() method. - it('cannot test', () => {}); - return; - } - it('can encode a reply', async () => { const body = await ReactServerDOMClient.encodeReply({some: 'object'}); const decoded = await ReactServerDOMServer.decodeReply( @@ -89,6 +81,8 @@ describe('ReactFlightDOMReplyEdge', () => { ); expect(result).toEqual(buffers); + // Array buffers can't use the toEqual helper. + expect(new Uint8Array(result[0])).toEqual(new Uint8Array(buffers[0])); }); // @gate enableBinaryFlight @@ -109,35 +103,33 @@ describe('ReactFlightDOMReplyEdge', () => { expect(await result.arrayBuffer()).toEqual(await blob.arrayBuffer()); }); - if (typeof FormData !== 'undefined' && typeof File !== 'undefined') { - it('can transport FormData (blobs)', async () => { - const bytes = new Uint8Array([ - 123, 4, 10, 5, 100, 255, 244, 45, 56, 67, 43, 124, 67, 89, 100, 20, - ]); - const blob = new Blob([bytes, bytes], { - type: 'application/x-test', - }); - - const formData = new FormData(); - formData.append('hi', 'world'); - formData.append('file', blob, 'filename.test'); - - expect(formData.get('file') instanceof File).toBe(true); - expect(formData.get('file').name).toBe('filename.test'); - - const body = await ReactServerDOMClient.encodeReply(formData); - const result = await ReactServerDOMServer.decodeReply( - body, - webpackServerMap, - ); - - expect(result instanceof FormData).toBe(true); - expect(result.get('hi')).toBe('world'); - const resultBlob = result.get('file'); - expect(resultBlob instanceof Blob).toBe(true); - expect(resultBlob.name).toBe('filename.test'); // In this direction we allow file name to pass through but not other direction. - expect(resultBlob.size).toBe(bytes.length * 2); - expect(await resultBlob.arrayBuffer()).toEqual(await blob.arrayBuffer()); + it('can transport FormData (blobs)', async () => { + const bytes = new Uint8Array([ + 123, 4, 10, 5, 100, 255, 244, 45, 56, 67, 43, 124, 67, 89, 100, 20, + ]); + const blob = new Blob([bytes, bytes], { + type: 'application/x-test', }); - } + + const formData = new FormData(); + formData.append('hi', 'world'); + formData.append('file', blob, 'filename.test'); + + expect(formData.get('file') instanceof File).toBe(true); + expect(formData.get('file').name).toBe('filename.test'); + + const body = await ReactServerDOMClient.encodeReply(formData); + const result = await ReactServerDOMServer.decodeReply( + body, + webpackServerMap, + ); + + expect(result instanceof FormData).toBe(true); + expect(result.get('hi')).toBe('world'); + const resultBlob = result.get('file'); + expect(resultBlob instanceof Blob).toBe(true); + expect(resultBlob.name).toBe('filename.test'); // In this direction we allow file name to pass through but not other direction. + expect(resultBlob.size).toBe(bytes.length * 2); + expect(await resultBlob.arrayBuffer()).toEqual(await blob.arrayBuffer()); + }); }); diff --git a/yarn.lock b/yarn.lock index b68244928f09..e56d08d371ca 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2182,6 +2182,11 @@ resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.0.0.tgz#1a9e4b4c96d8c7886e0110ed310a0135144a1691" integrity sha512-RThY/MnKrhubF6+s1JflwUjPEsnCEmYCWwqa/aRISKWNXGZ9epUwft4bUMM35SdKF9xvBrLydAM1RDHd1Z//ZQ== +"@fastify/busboy@^2.0.0": + version "2.1.1" + resolved "https://registry.yarnpkg.com/@fastify/busboy/-/busboy-2.1.1.tgz#b9da6a878a371829a0502c9b6c1c143ef6663f4d" + integrity sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA== + "@gitbeaker/core@^21.7.0": version "21.7.0" resolved "https://registry.yarnpkg.com/@gitbeaker/core/-/core-21.7.0.tgz#fcf7a12915d39f416e3f316d0a447a814179b8e5" @@ -15762,6 +15767,13 @@ unc-path-regex@^0.1.0, unc-path-regex@^0.1.2: resolved "https://registry.yarnpkg.com/unc-path-regex/-/unc-path-regex-0.1.2.tgz#e73dd3d7b0d7c5ed86fbac6b0ae7d8c6a69d50fa" integrity sha1-5z3T17DXxe2G+6xrCufYxqadUPo= +undici@^5.28.4: + version "5.28.4" + resolved "https://registry.yarnpkg.com/undici/-/undici-5.28.4.tgz#6b280408edb6a1a604a9b20340f45b422e373068" + integrity sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g== + dependencies: + "@fastify/busboy" "^2.0.0" + unicode-canonical-property-names-ecmascript@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz#2619800c4c825800efdd8343af7dd9933cbe2818"