Skip to content

Commit e42caf6

Browse files
authored
feat: Add support for file upload as binary data via Buffer, Readable, ReadableStream (#2925)
1 parent a46908c commit e42caf6

8 files changed

Lines changed: 727 additions & 8 deletions

File tree

integration/test/ParseFileTest.js

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use strict';
22

33
const assert = require('assert');
4+
const { Readable } = require('stream');
45
const Parse = require('../../node');
56

67
describe('Parse.File', () => {
@@ -144,6 +145,69 @@ describe('Parse.File', () => {
144145
}
145146
});
146147

148+
it('can save file from Buffer', async () => {
149+
const data = Buffer.from([61, 170, 236, 120]);
150+
const file = new Parse.File('buffer-file.bin', data);
151+
await file.save();
152+
153+
assert(file.url());
154+
assert(file.name());
155+
156+
const object = new Parse.Object('TestObject');
157+
object.set('file', file);
158+
await object.save();
159+
160+
const query = new Parse.Query('TestObject');
161+
const result = await query.get(object.id);
162+
assert.equal(result.get('file').name(), file.name());
163+
assert.equal(result.get('file').url(), file.url());
164+
165+
const retrieved = await file.getData();
166+
assert.equal(retrieved, data.toString('base64'));
167+
});
168+
169+
it('can save file from Buffer with content type', async () => {
170+
const data = Buffer.from('hello world');
171+
const file = new Parse.File('hello.txt', data, 'text/plain');
172+
await file.save();
173+
174+
assert(file.url());
175+
const retrieved = await file.getData();
176+
assert.equal(Buffer.from(retrieved, 'base64').toString(), 'hello world');
177+
});
178+
179+
it('can save file from Readable stream', async () => {
180+
const data = Buffer.from([61, 170, 236, 120]);
181+
const stream = Readable.from(data);
182+
const file = new Parse.File('stream-file.bin', stream);
183+
await file.save();
184+
185+
assert(file.url());
186+
assert(file.name());
187+
188+
const object = new Parse.Object('TestObject');
189+
object.set('file', file);
190+
await object.save();
191+
192+
const query = new Parse.Query('TestObject');
193+
const result = await query.get(object.id);
194+
assert.equal(result.get('file').name(), file.name());
195+
assert.equal(result.get('file').url(), file.url());
196+
197+
const retrieved = await file.getData();
198+
assert.equal(retrieved, data.toString('base64'));
199+
});
200+
201+
it('can save file from Readable stream with content type', async () => {
202+
const stream = Readable.from(Buffer.from('hello world'));
203+
const file = new Parse.File('hello.txt', stream, 'text/plain');
204+
await file.save();
205+
206+
assert(file.url());
207+
const retrieved = await file.getData();
208+
assert.equal(Buffer.from(retrieved, 'base64').toString(), 'hello world');
209+
});
210+
147211
it('can save file to localDatastore', async () => {
148212
Parse.enableLocalDatastore();
149213
const file = new Parse.File('parse-js-sdk', [61, 170, 236, 120]);

src/CoreManager.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ export interface FileController {
4040
source: FileSource,
4141
options?: FileSaveOptions
4242
) => Promise<{ name: string; url: string }>;
43+
saveBinary?: (
44+
name: string,
45+
source: FileSource,
46+
options?: FileSaveOptions
47+
) => Promise<{ name: string; url: string }>;
4348
download: (uri: string, options?: any) => Promise<{ base64?: string; contentType?: string }>;
4449
deleteFile: (name: string, options?: { useMasterKey?: boolean }) => Promise<void>;
4550
}

src/ParseFile.ts

Lines changed: 168 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@ import CoreManager from './CoreManager';
33
import type { FullOptions } from './RESTController';
44
import ParseError from './ParseError';
55

6+
let NodeReadable: any;
7+
if (process.env.PARSE_BUILD === 'node') {
8+
NodeReadable = require('stream').Readable;
9+
}
10+
611
interface Base64 {
712
base64: string;
813
}
@@ -29,6 +34,16 @@ export type FileSource =
2934
format: 'uri';
3035
uri: string;
3136
type: string | undefined;
37+
}
38+
| {
39+
format: 'buffer';
40+
buffer: any;
41+
type: string | undefined;
42+
}
43+
| {
44+
format: 'stream';
45+
stream: any;
46+
type: string | undefined;
3247
};
3348

3449
export function b64Digit(number: number): string {
@@ -75,8 +90,13 @@ class ParseFile {
7590
* 1. an Array of byte value Numbers or Uint8Array.
7691
* 2. an Object like { base64: "..." } with a base64-encoded String.
7792
* 3. an Object like { uri: "..." } with a uri String.
78-
* 4. a File object selected with a file upload control. (3) only works
79-
* in Firefox 3.6+, Safari 6.0.2+, Chrome 7+, and IE 10+.
93+
* 4. a File object selected with a file upload control.
94+
* 5. (Node.js only) a Buffer. Uploaded as raw binary data instead of
95+
* base64-encoding, reducing memory usage. Falls back to base64
96+
* JSON encoding if metadata or tags are set.
97+
* 6. (Node.js only) a Readable stream, or a Web ReadableStream.
98+
* Streamed as raw binary data directly into the upload request.
99+
* Throws if metadata or tags are set.
80100
* For example:
81101
* <pre>
82102
* var fileUploadControl = $("#profilePhotoFileUpload")[0];
@@ -104,7 +124,30 @@ class ParseFile {
104124
this._tags = tags || {};
105125

106126
if (data !== undefined) {
107-
if (Array.isArray(data) || data instanceof Uint8Array) {
127+
if (typeof Buffer !== 'undefined' && Buffer.isBuffer(data)) {
128+
this._source = {
129+
format: 'buffer',
130+
buffer: data,
131+
type: specifiedType,
132+
};
133+
} else if (
134+
data !== null &&
135+
typeof data === 'object' &&
136+
typeof (data as any).pipe === 'function' &&
137+
typeof (data as any).read === 'function'
138+
) {
139+
this._source = {
140+
format: 'stream',
141+
stream: data,
142+
type: specifiedType,
143+
};
144+
} else if (typeof ReadableStream !== 'undefined' && data instanceof ReadableStream) {
145+
this._source = {
146+
format: 'stream',
147+
stream: data,
148+
type: specifiedType,
149+
};
150+
} else if (Array.isArray(data) || data instanceof Uint8Array) {
108151
this._data = ParseFile.encodeBase64(data);
109152
this._source = {
110153
format: 'base64',
@@ -165,6 +208,10 @@ class ParseFile {
165208
if (this._data) {
166209
return this._data;
167210
}
211+
if (this._source?.format === 'buffer') {
212+
this._data = this._source.buffer.toString('base64');
213+
return this._data;
214+
}
168215
if (!this._url) {
169216
throw new Error('Cannot retrieve data for unsaved ParseFile.');
170217
}
@@ -227,6 +274,12 @@ class ParseFile {
227274
/**
228275
* Saves the file to the Parse cloud.
229276
*
277+
* In Node.js, files created with Buffer or ReadableStream are uploaded as
278+
* raw binary data, avoiding base64 encoding overhead. If metadata
279+
* or tags are set on a Buffer-backed file, the upload falls back to base64
280+
* JSON encoding (since the binary endpoint does not support metadata).
281+
* Stream-backed files with metadata or tags will throw an error.
282+
*
230283
* @param {object} options
231284
* Valid options are:<ul>
232285
* <li>useMasterKey: In Cloud Code and Node only, causes the Master Key to
@@ -255,7 +308,50 @@ class ParseFile {
255308

256309
const controller = CoreManager.getFileController();
257310
if (!this._previousSave) {
258-
if (this._source.format === 'file') {
311+
if (this._source.format === 'buffer' || this._source.format === 'stream') {
312+
const hasMetadataOrTags =
313+
(this._metadata && Object.keys(this._metadata).length > 0) ||
314+
(this._tags && Object.keys(this._tags).length > 0);
315+
316+
if (this._source.format === 'stream' && hasMetadataOrTags) {
317+
throw new Error(
318+
'Cannot save a stream-based file with metadata or tags. Use a Buffer instead.'
319+
);
320+
}
321+
if (this._source.format === 'stream' && !controller.saveBinary) {
322+
throw new Error(
323+
'Cannot save a stream-based file without saveBinary support on the FileController.'
324+
);
325+
}
326+
327+
if (!hasMetadataOrTags && controller.saveBinary) {
328+
// Binary upload via ajax
329+
this._previousSave = controller
330+
.saveBinary(this._name, this._source, options)
331+
.then(res => {
332+
this._name = res.name;
333+
this._url = res.url;
334+
this._data = null;
335+
this._requestTask = null;
336+
return this;
337+
});
338+
} else if (this._source.format === 'buffer') {
339+
// Buffer: fall back to base64 JSON encoding (metadata/tags or no saveBinary)
340+
const base64Source = {
341+
format: 'base64' as const,
342+
base64: this._source.buffer.toString('base64'),
343+
type: this._source.type,
344+
};
345+
this._previousSave = controller
346+
.saveBase64(this._name, base64Source, options)
347+
.then(res => {
348+
this._name = res.name;
349+
this._url = res.url;
350+
this._requestTask = null;
351+
return this;
352+
});
353+
}
354+
} else if (this._source.format === 'file') {
259355
this._previousSave = controller.saveFile(this._name, this._source, options).then(res => {
260356
this._name = res.name;
261357
this._url = res.url;
@@ -485,6 +581,74 @@ const DefaultController = {
485581
return CoreManager.getRESTController().request('POST', path, data, options);
486582
},
487583

584+
saveBinary: async function (
585+
name: string,
586+
source: FileSource,
587+
options: FileSaveOptions = {}
588+
) {
589+
if (source.format !== 'buffer' && source.format !== 'stream') {
590+
throw new Error('saveBinary can only be used with Buffer or Stream sources.');
591+
}
592+
593+
const headers: Record<string, string> = {
594+
'X-Parse-Application-ID': CoreManager.get('APPLICATION_ID'),
595+
};
596+
headers['Content-Type'] = (source.type || 'application/octet-stream').replace(/[\r\n]/g, '');
597+
const jsKey = CoreManager.get('JAVASCRIPT_KEY');
598+
if (jsKey) {
599+
headers['X-Parse-JavaScript-Key'] = jsKey;
600+
}
601+
let useMasterKey = options.useMasterKey;
602+
if (typeof useMasterKey === 'undefined') {
603+
useMasterKey = CoreManager.get('USE_MASTER_KEY');
604+
}
605+
if (useMasterKey) {
606+
if (CoreManager.get('MASTER_KEY')) {
607+
delete headers['X-Parse-JavaScript-Key'];
608+
headers['X-Parse-Master-Key'] = CoreManager.get('MASTER_KEY');
609+
} else {
610+
throw new Error('Cannot use the Master Key, it has not been provided.');
611+
}
612+
}
613+
614+
if (options.sessionToken) {
615+
headers['X-Parse-Session-Token'] = options.sessionToken;
616+
} else {
617+
const userController = CoreManager.getUserController();
618+
if (userController) {
619+
const user = await userController.currentUserAsync();
620+
if (user) {
621+
const token = user.getSessionToken();
622+
if (token) {
623+
headers['X-Parse-Session-Token'] = token;
624+
}
625+
}
626+
}
627+
}
628+
629+
let body: any;
630+
if (source.format === 'buffer') {
631+
body = source.buffer;
632+
} else if (source.format === 'stream') {
633+
const stream = source.stream;
634+
if (typeof stream.pipe === 'function' && typeof stream.read === 'function') {
635+
body = NodeReadable.toWeb(stream);
636+
} else {
637+
body = stream;
638+
}
639+
}
640+
641+
let url = CoreManager.get('SERVER_URL');
642+
if (url[url.length - 1] !== '/') {
643+
url += '/';
644+
}
645+
url += 'files/' + encodeURIComponent(name);
646+
647+
return CoreManager.getRESTController()
648+
.ajax('POST', url, body, headers, options)
649+
.then(({ response }) => response);
650+
},
651+
488652
download: async function (uri, options) {
489653
const controller = new AbortController();
490654
options.requestTask(controller);

src/RESTController.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,9 @@ const RESTController = {
159159
};
160160
if (data) {
161161
fetchOptions.body = data;
162+
if (typeof ReadableStream !== 'undefined' && data instanceof ReadableStream) {
163+
fetchOptions.duplex = 'half';
164+
}
162165
}
163166
const response = await fetch(url, fetchOptions);
164167
const { status } = response;
@@ -215,7 +218,9 @@ const RESTController = {
215218
});
216219
} else if (status >= 500 || status === 0) {
217220
// retry on 5XX or library error
218-
if (++attempts < CoreManager.get('REQUEST_ATTEMPT_LIMIT')) {
221+
// ReadableStream bodies cannot be retried because they are consumed by the first fetch
222+
const isStream = typeof ReadableStream !== 'undefined' && data instanceof ReadableStream;
223+
if (!isStream && ++attempts < CoreManager.get('REQUEST_ATTEMPT_LIMIT')) {
219224
// Exponentially-growing random delay
220225
const delay = Math.round(Math.random() * 125 * Math.pow(2, attempts));
221226
setTimeout(dispatch, delay);

0 commit comments

Comments
 (0)