From cbb6f2b5461cdce282c7e47b9c68a0897d393383 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Fri, 5 Apr 2024 12:49:25 -0400 Subject: [PATCH] [Flight] Support Blobs from Server to Client (#28755) We currently support Blobs when passing from Client to Server so this adds it in the other direction for parity - when `enableFlightBinary` is enabled. We intentionally only support the `Blob` type to pass-through, not subtype `File`. That's because passing additional meta data like filename might be an accidental leak. You can still pass a `File` through but it'll appear as a `Blob` on the other side. It's also not possible to create a faithful File subclass in all environments without it actually being backed by a file. This implementation isn't great but at least it works. It creates a few indirections. This is because we need to be able to asynchronously emit the buffers but we have to "block" the parent object from resolving while it's loading. Ideally, we should be able to create the Blob on the client early and then stream in it lazily. Because the Blob API doesn't guarantee that the data is available synchronously. Unfortunately, the native APIs doesn't have this. We could implement custom versions of all the data read APIs but then the blobs still wouldn't work with native APIs. So we just have to wait until Blob accepts a stream in the constructor. We should be able to stream each chunk early in the protocol though even though we can't unblock the parent until they've all loaded. I didn't do this yet mostly because of code structure and I'm lazy. --- .../react-client/src/ReactFlightClient.js | 9 ++++ .../src/__tests__/ReactFlightDOMEdge-test.js | 26 ++++++++++ .../react-server/src/ReactFlightServer.js | 50 +++++++++++++++++++ 3 files changed, 85 insertions(+) diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index c0cec0db366c..8a35e97b1679 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -737,6 +737,15 @@ function parseModelString( const data = getOutlinedModel(response, id); return new Set(data); } + case 'B': { + // Blob + if (enableBinaryFlight) { + const id = parseInt(value.slice(2), 16); + const data = getOutlinedModel(response, id); + return new Blob(data.slice(1), {type: data[0]}); + } + return undefined; + } case 'I': { // $Infinity return Infinity; 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 8304d9927d37..ada7bb35cae2 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js @@ -5,6 +5,7 @@ * LICENSE file in the root directory of this source tree. * * @emails react-core + * @jest-environment ./scripts/jest/ReactDOMServerIntegrationEnvironment */ 'use strict'; @@ -14,6 +15,9 @@ 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; +} // Don't wait before processing work on the server. // TODO: we can replace this with FlightServer.act(). @@ -326,6 +330,28 @@ describe('ReactFlightDOMEdge', () => { expect(result).toEqual(buffers); }); + // @gate enableBinaryFlight + it('should be able to serialize a blob', 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 stream = passThrough( + ReactServerDOMServer.renderToReadableStream(blob), + ); + const result = await ReactServerDOMClient.createFromReadableStream(stream, { + ssrManifest: { + moduleMap: null, + moduleLoading: null, + }, + }); + expect(result instanceof Blob).toBe(true); + expect(result.size).toBe(bytes.length * 2); + expect(await result.arrayBuffer()).toEqual(await blob.arrayBuffer()); + }); + it('warns if passing a this argument to bind() of a server reference', async () => { const ServerModule = serverExports({ greet: function () {}, diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index a8c648881fe2..4c8fa75fec9e 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -239,6 +239,8 @@ export type ReactClientValue = | Array | Map | Set + | $ArrayBufferView + | ArrayBuffer | Date | ReactClientObject | Promise; // Thenable @@ -1229,6 +1231,46 @@ function serializeTypedArray( return serializeByValueID(bufferId); } +function serializeBlob(request: Request, blob: Blob): string { + const id = request.nextChunkId++; + request.pendingChunks++; + + const reader = blob.stream().getReader(); + + const model: Array = [blob.type]; + + function progress( + entry: {done: false, value: Uint8Array} | {done: true, value: void}, + ): Promise | void { + if (entry.done) { + const blobId = outlineModel(request, model); + const blobReference = '$B' + blobId.toString(16); + const processedChunk = encodeReferenceChunk(request, id, blobReference); + request.completedRegularChunks.push(processedChunk); + if (request.destination !== null) { + flushCompletedChunks(request, request.destination); + } + return; + } + // TODO: Emit the chunk early and refer to it later. + model.push(entry.value); + // $FlowFixMe[incompatible-call] + return reader.read().then(progress).catch(error); + } + + function error(reason: mixed) { + const digest = logRecoverableError(request, reason); + emitErrorChunk(request, id, digest, reason); + if (request.destination !== null) { + flushCompletedChunks(request, request.destination); + } + } + // $FlowFixMe[incompatible-call] + reader.read().then(progress).catch(error); + + return '$' + id.toString(16); +} + function escapeStringValue(value: string): string { if (value[0] === '$') { // We need to escape $ prefixed strings since we use those to encode @@ -1606,6 +1648,10 @@ function renderModelDestructive( if (value instanceof DataView) { return serializeTypedArray(request, 'V', value); } + // TODO: Blob is not available in old Node. Remove the typeof check later. + if (typeof Blob === 'function' && value instanceof Blob) { + return serializeBlob(request, value); + } } const iteratorFn = getIteratorFn(value); @@ -2146,6 +2192,10 @@ function renderConsoleValue( if (value instanceof DataView) { return serializeTypedArray(request, 'V', value); } + // TODO: Blob is not available in old Node. Remove the typeof check later. + if (typeof Blob === 'function' && value instanceof Blob) { + return serializeBlob(request, value); + } } const iteratorFn = getIteratorFn(value);