Skip to content

Commit d3ca465

Browse files
authored
feat: Add Parse.File upload and download progress in browser and Node environments (#2503)
1 parent b78e9aa commit d3ca465

22 files changed

+971
-2247
lines changed

integration/test/IdempotencyTest.js

Lines changed: 13 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,39 @@
11
'use strict';
2+
const originalFetch = global.fetch;
23

34
const Parse = require('../../node');
45
const sleep = require('./sleep');
5-
66
const Item = Parse.Object.extend('IdempotencyItem');
7-
const RESTController = Parse.CoreManager.getRESTController();
87

9-
const XHR = RESTController._getXHR();
10-
function DuplicateXHR(requestId) {
11-
function XHRWrapper() {
12-
const xhr = new XHR();
13-
const send = xhr.send;
14-
xhr.send = function () {
15-
this.setRequestHeader('X-Parse-Request-Id', requestId);
16-
send.apply(this, arguments);
17-
};
18-
return xhr;
19-
}
20-
return XHRWrapper;
8+
function DuplicateRequestId(requestId) {
9+
global.fetch = async (...args) => {
10+
const options = args[1];
11+
options.headers['X-Parse-Request-Id'] = requestId;
12+
return originalFetch(...args);
13+
};
2114
}
2215

2316
describe('Idempotency', () => {
24-
beforeEach(() => {
25-
RESTController._setXHR(XHR);
17+
afterEach(() => {
18+
global.fetch = originalFetch;
2619
});
2720

2821
it('handle duplicate cloud code function request', async () => {
29-
RESTController._setXHR(DuplicateXHR('1234'));
22+
DuplicateRequestId('1234');
3023
await Parse.Cloud.run('CloudFunctionIdempotency');
3124
await expectAsync(Parse.Cloud.run('CloudFunctionIdempotency')).toBeRejectedWithError(
3225
'Duplicate request'
3326
);
3427
await expectAsync(Parse.Cloud.run('CloudFunctionIdempotency')).toBeRejectedWithError(
3528
'Duplicate request'
3629
);
37-
3830
const query = new Parse.Query(Item);
3931
const results = await query.find();
4032
expect(results.length).toBe(1);
4133
});
4234

4335
it('handle duplicate job request', async () => {
44-
RESTController._setXHR(DuplicateXHR('1234'));
36+
DuplicateRequestId('1234');
4537
const params = { startedBy: 'Monty Python' };
4638
const jobStatusId = await Parse.Cloud.startJob('CloudJob1', params);
4739
await expectAsync(Parse.Cloud.startJob('CloudJob1', params)).toBeRejectedWithError(
@@ -61,12 +53,12 @@ describe('Idempotency', () => {
6153
});
6254

6355
it('handle duplicate POST / PUT request', async () => {
64-
RESTController._setXHR(DuplicateXHR('1234'));
56+
DuplicateRequestId('1234');
6557
const testObject = new Parse.Object('IdempotentTest');
6658
await testObject.save();
6759
await expectAsync(testObject.save()).toBeRejectedWithError('Duplicate request');
6860

69-
RESTController._setXHR(DuplicateXHR('5678'));
61+
DuplicateRequestId('5678');
7062
testObject.set('foo', 'bar');
7163
await testObject.save();
7264
await expectAsync(testObject.save()).toBeRejectedWithError('Duplicate request');

integration/test/ParseFileTest.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,29 @@ describe('Parse.File', () => {
4343
file.cancel();
4444
});
4545

46+
it('can get file upload / download progress', async () => {
47+
const file = new Parse.File('parse-js-test-file', [61, 170, 236, 120]);
48+
let progress = 0;
49+
await file.save({
50+
progress: (value, loaded, total) => {
51+
progress = value;
52+
expect(loaded).toBeDefined();
53+
expect(total).toBeDefined();
54+
},
55+
});
56+
expect(progress).toBe(1);
57+
progress = 0;
58+
file._data = null;
59+
await file.getData({
60+
progress: (value, loaded, total) => {
61+
progress = value;
62+
expect(loaded).toBeDefined();
63+
expect(total).toBeDefined();
64+
},
65+
});
66+
expect(progress).toBe(1);
67+
});
68+
4669
it('can not get data from unsaved file', async () => {
4770
const file = new Parse.File('parse-server-logo', [61, 170, 236, 120]);
4871
file._data = null;

integration/test/ParseLocalDatastoreTest.js

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,6 @@ function runTest(controller) {
3838
Parse.initialize('integration');
3939
Parse.CoreManager.set('SERVER_URL', serverURL);
4040
Parse.CoreManager.set('MASTER_KEY', 'notsosecret');
41-
const RESTController = Parse.CoreManager.getRESTController();
42-
RESTController._setXHR(require('xmlhttprequest').XMLHttpRequest);
4341
Parse.enableLocalDatastore();
4442
});
4543

@@ -1082,8 +1080,6 @@ function runTest(controller) {
10821080
Parse.initialize('integration');
10831081
Parse.CoreManager.set('SERVER_URL', serverURL);
10841082
Parse.CoreManager.set('MASTER_KEY', 'notsosecret');
1085-
const RESTController = Parse.CoreManager.getRESTController();
1086-
RESTController._setXHR(require('xmlhttprequest').XMLHttpRequest);
10871083
Parse.enableLocalDatastore();
10881084

10891085
const numbers = [];

integration/test/ParseReactNativeTest.js

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,6 @@ const LocalDatastoreController =
88
const StorageController = require('../../lib/react-native/StorageController.default').default;
99
const RESTController = require('../../lib/react-native/RESTController').default;
1010

11-
RESTController._setXHR(require('xmlhttprequest').XMLHttpRequest);
12-
1311
describe('Parse React Native', () => {
1412
beforeEach(() => {
1513
// Set up missing controllers and configurations

package-lock.json

Lines changed: 4 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,7 @@
3535
"idb-keyval": "6.2.1",
3636
"react-native-crypto-js": "1.0.0",
3737
"uuid": "10.0.0",
38-
"ws": "8.18.1",
39-
"xmlhttprequest": "1.8.0"
38+
"ws": "8.18.1"
4039
},
4140
"devDependencies": {
4241
"@babel/core": "7.26.10",

src/ParseFile.ts

Lines changed: 62 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,7 @@
1-
/* global XMLHttpRequest, Blob */
1+
/* global Blob */
22
import CoreManager from './CoreManager';
33
import type { FullOptions } from './RESTController';
44
import ParseError from './ParseError';
5-
import XhrWeapp from './Xhr.weapp';
6-
7-
let XHR: any = null;
8-
if (typeof XMLHttpRequest !== 'undefined') {
9-
XHR = XMLHttpRequest;
10-
}
11-
if (process.env.PARSE_BUILD === 'weapp') {
12-
XHR = XhrWeapp;
13-
}
145

156
interface Base64 {
167
base64: string;
@@ -155,18 +146,29 @@ class ParseFile {
155146
* Data is present if initialized with Byte Array, Base64 or Saved with Uri.
156147
* Data is cleared if saved with File object selected with a file upload control
157148
*
149+
* @param {object} options
150+
* @param {function} [options.progress] callback for download progress
151+
* <pre>
152+
* const parseFile = new Parse.File(name, file);
153+
* parseFile.getData({
154+
* progress: (progressValue, loaded, total) => {
155+
* if (progressValue !== null) {
156+
* // Update the UI using progressValue
157+
* }
158+
* }
159+
* });
160+
* </pre>
158161
* @returns {Promise} Promise that is resolve with base64 data
159162
*/
160-
async getData(): Promise<string> {
163+
async getData(options?: { progress?: () => void }): Promise<string> {
164+
options = options || {};
161165
if (this._data) {
162166
return this._data;
163167
}
164168
if (!this._url) {
165169
throw new Error('Cannot retrieve data for unsaved ParseFile.');
166170
}
167-
const options = {
168-
requestTask: task => (this._requestTask = task),
169-
};
171+
(options as any).requestTask = task => (this._requestTask = task);
170172
const controller = CoreManager.getFileController();
171173
const result = await controller.download(this._url, options);
172174
this._data = result.base64;
@@ -231,12 +233,12 @@ class ParseFile {
231233
* be used for this request.
232234
* <li>sessionToken: A valid session token, used for making a request on
233235
* behalf of a specific user.
234-
* <li>progress: In Browser only, callback for upload progress. For example:
236+
* <li>progress: callback for upload progress. For example:
235237
* <pre>
236238
* let parseFile = new Parse.File(name, file);
237239
* parseFile.save({
238-
* progress: (progressValue, loaded, total, { type }) => {
239-
* if (type === "upload" && progressValue !== null) {
240+
* progress: (progressValue, loaded, total) => {
241+
* if (progressValue !== null) {
240242
* // Update the UI using progressValue
241243
* }
242244
* }
@@ -483,58 +485,50 @@ const DefaultController = {
483485
return CoreManager.getRESTController().request('POST', path, data, options);
484486
},
485487

486-
download: function (uri, options) {
487-
if (XHR) {
488-
return this.downloadAjax(uri, options);
489-
} else if (process.env.PARSE_BUILD === 'node') {
490-
return new Promise((resolve, reject) => {
491-
const client = uri.indexOf('https') === 0 ? require('https') : require('http');
492-
const req = client.get(uri, resp => {
493-
resp.setEncoding('base64');
494-
let base64 = '';
495-
resp.on('data', data => (base64 += data));
496-
resp.on('end', () => {
497-
resolve({
498-
base64,
499-
contentType: resp.headers['content-type'],
500-
});
501-
});
502-
});
503-
req.on('abort', () => {
504-
resolve({});
505-
});
506-
req.on('error', reject);
507-
options.requestTask(req);
508-
});
509-
} else {
510-
return Promise.reject('Cannot make a request: No definition of XMLHttpRequest was found.');
511-
}
512-
},
513-
514-
downloadAjax: function (uri: string, options: any) {
515-
return new Promise((resolve, reject) => {
516-
const xhr = new XHR();
517-
xhr.open('GET', uri, true);
518-
xhr.responseType = 'arraybuffer';
519-
xhr.onerror = function (e) {
520-
reject(e);
521-
};
522-
xhr.onreadystatechange = function () {
523-
if (xhr.readyState !== xhr.DONE) {
524-
return;
525-
}
526-
if (!this.response) {
527-
return resolve({});
488+
download: async function (uri, options) {
489+
const controller = new AbortController();
490+
options.requestTask(controller);
491+
const { signal } = controller;
492+
try {
493+
const response = await fetch(uri, { signal });
494+
const reader = response.body.getReader();
495+
const length = +response.headers.get('Content-Length') || 0;
496+
const contentType = response.headers.get('Content-Type');
497+
if (length === 0) {
498+
options.progress?.(null, null, null);
499+
return {
500+
base64: '',
501+
contentType,
502+
};
503+
}
504+
let recieved = 0;
505+
const chunks = [];
506+
while (true) {
507+
const { done, value } = await reader.read();
508+
if (done) {
509+
break;
528510
}
529-
const bytes = new Uint8Array(this.response);
530-
resolve({
531-
base64: ParseFile.encodeBase64(bytes),
532-
contentType: xhr.getResponseHeader('content-type'),
533-
});
511+
chunks.push(value);
512+
recieved += value?.length || 0;
513+
options.progress?.(recieved / length, recieved, length);
514+
}
515+
const body = new Uint8Array(recieved);
516+
let offset = 0;
517+
for (const chunk of chunks) {
518+
body.set(chunk, offset);
519+
offset += chunk.length;
520+
}
521+
return {
522+
base64: ParseFile.encodeBase64(body),
523+
contentType,
534524
};
535-
options.requestTask(xhr);
536-
xhr.send();
537-
});
525+
} catch (error) {
526+
if (error.name === 'AbortError') {
527+
return {};
528+
} else {
529+
throw error;
530+
}
531+
}
538532
},
539533

540534
deleteFile: function (name: string, options?: FullOptions) {
@@ -553,21 +547,13 @@ const DefaultController = {
553547
.ajax('DELETE', url, '', headers)
554548
.catch(response => {
555549
// TODO: return JSON object in server
556-
if (!response || response === 'SyntaxError: Unexpected end of JSON input') {
550+
if (!response || response.toString() === 'SyntaxError: Unexpected end of JSON input') {
557551
return Promise.resolve();
558552
} else {
559553
return CoreManager.getRESTController().handleError(response);
560554
}
561555
});
562556
},
563-
564-
_setXHR(xhr: any) {
565-
XHR = xhr;
566-
},
567-
568-
_getXHR() {
569-
return XHR;
570-
},
571557
};
572558

573559
CoreManager.setFileController(DefaultController);

src/ParseObject.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2552,7 +2552,6 @@ const DefaultController = {
25522552
const status = responses[index]._status;
25532553
delete responses[index]._status;
25542554
delete responses[index]._headers;
2555-
delete responses[index]._xhr;
25562555
mapIdForPin[objectId] = obj._localId;
25572556
obj._handleSaveResponse(responses[index].success, status);
25582557
} else {
@@ -2620,7 +2619,6 @@ const DefaultController = {
26202619
const status = response._status;
26212620
delete response._status;
26222621
delete response._headers;
2623-
delete response._xhr;
26242622
targetCopy._handleSaveResponse(response, status);
26252623
},
26262624
error => {

0 commit comments

Comments
 (0)