Skip to content

Commit

Permalink
[Flight] Support Blobs from Server to Client (#28755)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
sebmarkbage committed Apr 5, 2024
1 parent f33a6b6 commit cbb6f2b
Show file tree
Hide file tree
Showing 3 changed files with 85 additions and 0 deletions.
9 changes: 9 additions & 0 deletions packages/react-client/src/ReactFlightClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* LICENSE file in the root directory of this source tree.
*
* @emails react-core
* @jest-environment ./scripts/jest/ReactDOMServerIntegrationEnvironment
*/

'use strict';
Expand All @@ -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().
Expand Down Expand Up @@ -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 () {},
Expand Down
50 changes: 50 additions & 0 deletions packages/react-server/src/ReactFlightServer.js
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,8 @@ export type ReactClientValue =
| Array<ReactClientValue>
| Map<ReactClientValue, ReactClientValue>
| Set<ReactClientValue>
| $ArrayBufferView
| ArrayBuffer
| Date
| ReactClientObject
| Promise<ReactClientValue>; // Thenable<ReactClientValue>
Expand Down Expand Up @@ -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<string | Uint8Array> = [blob.type];

function progress(
entry: {done: false, value: Uint8Array} | {done: true, value: void},
): Promise<void> | 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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down

0 comments on commit cbb6f2b

Please sign in to comment.