1
+/**
2
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ * @emails react-core
8
+ * @jest-environment node
9
+ */
10
+
11
+'use strict';
12
+
13
+let webpackServerMap;
14
+let busboy;
15
+let ReactServerDOMServer;
16
+let ReactServerDOMClient;
17
+
18
+describe('ReactFlightDOMReplyNode', () => {
19
+ beforeEach(() => {
20
+ jest.resetModules();
21
+ // Simulate the condition resolution
22
+ jest.mock('react', () => require('react/react.react-server'));
23
+ jest.mock('react-server-dom-webpack/server', () =>
24
+ require('react-server-dom-webpack/server.node'),
25
+ );
26
+ const WebpackMock = require('./utils/WebpackMock');
27
+ webpackServerMap = WebpackMock.webpackServerMap;
28
+ ReactServerDOMServer = require('react-server-dom-webpack/server.node');
29
+ jest.resetModules();
30
+ ReactServerDOMClient = require('react-server-dom-webpack/client.node');
31
+
32
+ busboy = require('busboy');
33
+ });
34
+
35
+ // Writes the body to busboy as a multipart stream. Blob entries become
36
+ // `filename`-bearing parts so busboy emits them as 'file' events (with
37
+ // streamed data) rather than 'field' events.
38
+ async function pipeBodyToBusboy(bb, body, boundary) {
39
+ // eslint-disable-next-line no-for-of-loops/no-for-of-loops
40
+ for (const [name, value] of body) {
41
+ if (typeof value === 'string') {
42
+ bb.write(
43
+ `--${boundary}\r\n` +
44
+ `Content-Disposition: form-data; name="${name}"\r\n` +
45
+ `\r\n` +
46
+ `${value}\r\n`,
47
+ );
48
+ } else {
49
+ const filename =
50
+ typeof value.name === 'string' && value.name !== ''
51
+ ? value.name
52
+ : 'blob';
53
+ const mimeType =
54
+ typeof value.type === 'string' && value.type !== ''
55
+ ? value.type
56
+ : 'application/octet-stream';
57
+ const buffer = Buffer.from(await value.arrayBuffer());
58
+ bb.write(
59
+ `--${boundary}\r\n` +
60
+ `Content-Disposition: form-data; name="${name}"; filename="${filename}"\r\n` +
61
+ `Content-Type: ${mimeType}\r\n` +
62
+ `\r\n`,
63
+ );
64
+ bb.write(buffer);
65
+ bb.write('\r\n');
66
+ }
67
+ }
68
+ bb.end(`--${boundary}--\r\n`);
69
+ }
70
+
71
+ // FormData iterates entries in insertion order per spec, so a referenced
72
+ // FormData must round-trip with its entry order intact even when files
73
+ // and text fields are interleaved in the payload.
74
+ it('preserves entry order when referenced FormDatas interleave files and text', async () => {
75
+ const a = new FormData();
76
+ a.append('text_a', 'value_a');
77
+ a.append('file_a', new Blob(['content_a'], {type: 'text/plain'}), 'a.txt');
78
+ const b = new FormData();
79
+ b.append('text_b', 'value_b');
80
+ b.append('file_b', new Blob(['content_b'], {type: 'text/plain'}), 'b.txt');
81
+
82
+ const body = await ReactServerDOMClient.encodeReply([a, b]);
83
+ const boundary = 'boundary';
84
+ const bb = busboy({
85
+ headers: {
86
+ 'content-type': `multipart/form-data; boundary=${boundary}`,
87
+ },
88
+ });
89
+ const reply = ReactServerDOMServer.decodeReplyFromBusboy(
90
+ bb,
91
+ webpackServerMap,
92
+ );
93
+ await pipeBodyToBusboy(bb, body, boundary);
94
+
95
+ const result = await reply;
96
+ expect(result).toHaveLength(2);
97
+ const [decodedA, decodedB] = result;
98
+
99
+ const aEntries = Array.from(decodedA.entries());
100
+ expect(aEntries.map(([k]) => k)).toEqual(['text_a', 'file_a']);
101
+ expect(aEntries[0][1]).toBe('value_a');
102
+ expect(aEntries[1][1]).toBeInstanceOf(File);
103
+ expect(aEntries[1][1].name).toBe('a.txt');
104
+
105
+ const bEntries = Array.from(decodedB.entries());
106
+ expect(bEntries.map(([k]) => k)).toEqual(['text_b', 'file_b']);
107
+ expect(bEntries[0][1]).toBe('value_b');
108
+ expect(bEntries[1][1]).toBeInstanceOf(File);
109
+ expect(bEntries[1][1].name).toBe('b.txt');
110
+ });
111
+
112
+ // Every entry of a referenced FormData must be present in the decoded
113
+ // FormData regardless of where files appear in its iteration order.
114
+ it('does not drop entries when referenced FormDatas iterate files before text', async () => {
115
+ const a = new FormData();
116
+ a.append('file_a', new Blob(['content_a'], {type: 'text/plain'}), 'a.txt');
117
+ a.append('text_a', 'value_a');
118
+ const b = new FormData();
119
+ b.append('file_b', new Blob(['content_b'], {type: 'text/plain'}), 'b.txt');
120
+ b.append('text_b', 'value_b');
121
+
122
+ const body = await ReactServerDOMClient.encodeReply([a, b]);
123
+ const boundary = 'boundary';
124
+ const bb = busboy({
125
+ headers: {
126
+ 'content-type': `multipart/form-data; boundary=${boundary}`,
127
+ },
128
+ });
129
+ const reply = ReactServerDOMServer.decodeReplyFromBusboy(
130
+ bb,
131
+ webpackServerMap,
132
+ );
133
+ await pipeBodyToBusboy(bb, body, boundary);
134
+
135
+ const result = await reply;
136
+ expect(result).toHaveLength(2);
137
+ const [decodedA, decodedB] = result;
138
+
139
+ const aKeys = Array.from(decodedA.keys()).sort();
140
+ expect(aKeys).toEqual(['file_a', 'text_a']);
141
+ expect(decodedA.get('text_a')).toBe('value_a');
142
+ expect(decodedA.get('file_a')).toBeInstanceOf(File);
143
+
144
+ const bKeys = Array.from(decodedB.keys()).sort();
145
+ expect(bKeys).toEqual(['file_b', 'text_b']);
146
+ expect(decodedB.get('text_b')).toBe('value_b');
147
+ expect(decodedB.get('file_b')).toBeInstanceOf(File);
148
+ });
149
+});