@samitouri / QOS-React / commits / a21d1475ff

[Flight] Fix File Upload in Node.js (#26700)

Use the Blob constructor + append with filename instead of File constructor. Node.js doesn't expose a global File constructor but does support it in this form. Queue fields until we get the 'end' event from the previous file. We rely on previous files being available by the time a field is resolved. However, since the 'end' event in Readable is fired after two micro-tasks, these are not resolved in order. I use a queue of the fields while we're still waiting on files to finish. This still doesn't resolve files and fields in order relative to each other but that doesn't matter for our usage.

Sebastian Markbåge committed Apr 22, 2023 at 01:04 UTC a21d1475ffd7225a463f2d0c0c9b732c8dd795eb
4 files changed +39 -5
fixtures/flight/src/Form.js
+7 -1
@@ -20,8 +20,14 @@ export default function Form({action, children}) {
20 React.startTransition(() => setIsPending(false));
21 }
22 }}>
23 - <input name="name" />
23 + <label>
24 + Name: <input name="name" />
25 + </label>
26 + <label>
27 + File: <input type="file" name="file" />
28 + </label>
29 <button>Say Hi</button>
30 + {isPending ? 'Saving...' : null}
31 </form>
32 </ErrorBoundary>
33 );
fixtures/flight/src/actions.js
+8 -1
@@ -5,5 +5,12 @@ export async function like() {
5 }
6
7 export async function greet(formData) {
8 - return 'Hi ' + formData.get('name') + '!';
8 + const name = formData.get('name') || 'you';
9 + const file = formData.get('file');
10 + if (file) {
11 + return `Ok, ${name}, here is ${file.name}:
12 + ${(await file.text()).toUpperCase()}
13 + `;
14 + }
15 + return 'Hi ' + name + '!';
16 }
packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js
+19 -1
@@ -88,8 +88,17 @@ function decodeReplyFromBusboy<T>(
88 webpackMap: ServerManifest,
89 ): Thenable<T> {
90 const response = createResponse(webpackMap, '');
91 + let pendingFiles = 0;
92 + const queuedFields: Array<string> = [];
93 busboyStream.on('field', (name, value) => {
92 - resolveField(response, name, value);
94 + if (pendingFiles > 0) {
95 + // Because the 'end' event fires two microtasks after the next 'field'
96 + // we would resolve files and fields out of order. To handle this properly
97 + // we queue any fields we receive until the previous file is done.
98 + queuedFields.push(name, value);
99 + } else {
100 + resolveField(response, name, value);
101 + }
102 });
103 busboyStream.on('file', (name, value, {filename, encoding, mimeType}) => {
104 if (encoding.toLowerCase() === 'base64') {
@@ -99,12 +108,21 @@ function decodeReplyFromBusboy<T>(
108 'the wrong assumption, we can easily fix it.',
109 );
110 }
111 + pendingFiles++;
112 const file = resolveFileInfo(response, name, filename, mimeType);
113 value.on('data', chunk => {
114 resolveFileChunk(response, file, chunk);
115 });
116 value.on('end', () => {
117 resolveFileComplete(response, name, file);
118 + pendingFiles--;
119 + if (pendingFiles === 0) {
120 + // Release any queued fields
121 + for (let i = 0; i < queuedFields.length; i += 2) {
122 + resolveField(response, queuedFields[i], queuedFields[i + 1]);
123 + }
124 + queuedFields.length = 0;
125 + }
126 });
127 });
128 busboyStream.on('finish', () => {
packages/react-server/src/ReactFlightReplyServer.js
+5 -2
@@ -564,8 +564,11 @@ export function resolveFileComplete(
564 handle: FileHandle,
565 ): void {
566 // Add this file to the backing store.
567 - const file = new File(handle.chunks, handle.filename, {type: handle.mime});
568 - response._formData.append(key, file);
567 + // Node.js doesn't expose a global File constructor so we need to use
568 + // the append() form that takes the file name as the third argument,
569 + // to create a File object.
570 + const blob = new Blob(handle.chunks, {type: handle.mime});
571 + response._formData.append(key, blob, handle.filename);
572 }
573
574 export function close(response: Response): void {