Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Replace XMLHttpRequest with Fetch API #2503

Draft
wants to merge 7 commits into
base: alpha
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ jobs:
cache: npm
- run: npm ci
# Run unit tests
- run: npm test -- --maxWorkers=4
# - run: npm test -- --maxWorkers=4
# Run integration tests
- run: npm run test:mongodb
env:
Expand Down
34 changes: 13 additions & 21 deletions integration/test/IdempotencyTest.js
Original file line number Diff line number Diff line change
@@ -1,47 +1,39 @@
'use strict';
const originalFetch = global.fetch;

const Parse = require('../../node');
const sleep = require('./sleep');

const Item = Parse.Object.extend('IdempotencyItem');
const RESTController = Parse.CoreManager.getRESTController();

const XHR = RESTController._getXHR();
function DuplicateXHR(requestId) {
function XHRWrapper() {
const xhr = new XHR();
const send = xhr.send;
xhr.send = function () {
this.setRequestHeader('X-Parse-Request-Id', requestId);
send.apply(this, arguments);
};
return xhr;
}
return XHRWrapper;
function DuplicateRequestId(requestId) {
global.fetch = async (...args) => {
const options = args[1];
options.headers['X-Parse-Request-Id'] = requestId;
return originalFetch(...args);
};
}

describe('Idempotency', () => {
beforeEach(() => {
RESTController._setXHR(XHR);
afterEach(() => {
global.fetch = originalFetch;
});

it('handle duplicate cloud code function request', async () => {
RESTController._setXHR(DuplicateXHR('1234'));
DuplicateRequestId('1234');
await Parse.Cloud.run('CloudFunctionIdempotency');
await expectAsync(Parse.Cloud.run('CloudFunctionIdempotency')).toBeRejectedWithError(
'Duplicate request'
);
await expectAsync(Parse.Cloud.run('CloudFunctionIdempotency')).toBeRejectedWithError(
'Duplicate request'
);

const query = new Parse.Query(Item);
const results = await query.find();
expect(results.length).toBe(1);
});

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

it('handle duplicate POST / PUT request', async () => {
RESTController._setXHR(DuplicateXHR('1234'));
DuplicateRequestId('1234');
const testObject = new Parse.Object('IdempotentTest');
await testObject.save();
await expectAsync(testObject.save()).toBeRejectedWithError('Duplicate request');

RESTController._setXHR(DuplicateXHR('5678'));
DuplicateRequestId('5678');
testObject.set('foo', 'bar');
await testObject.save();
await expectAsync(testObject.save()).toBeRejectedWithError('Duplicate request');
Expand Down
25 changes: 25 additions & 0 deletions integration/test/ParseFileTest.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,31 @@ describe('Parse.File', () => {
file.cancel();
});

it('can get file upload / download progress', async () => {
const parseLogo =
'https://raw.githubusercontent.com/parse-community/parse-server/master/.github/parse-server-logo.png';
const file = new Parse.File('parse-server-logo', { uri: parseLogo });
let progress = 0;
await file.save({
progress: (value, loaded, total) => {
progress = value;
expect(loaded).toBeDefined();
expect(total).toBeDefined();
},
});
expect(progress).toBe(1);
progress = 0;
file._data = null;
await file.getData({
progress: (value, loaded, total) => {
progress = value;
expect(loaded).toBeDefined();
expect(total).toBeDefined();
},
});
expect(progress).toBe(1);
});

it('can not get data from unsaved file', async () => {
const file = new Parse.File('parse-server-logo', [61, 170, 236, 120]);
file._data = null;
Expand Down
4 changes: 0 additions & 4 deletions integration/test/ParseLocalDatastoreTest.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,6 @@ function runTest(controller) {
Parse.initialize('integration');
Parse.CoreManager.set('SERVER_URL', serverURL);
Parse.CoreManager.set('MASTER_KEY', 'notsosecret');
const RESTController = Parse.CoreManager.getRESTController();
RESTController._setXHR(require('xmlhttprequest').XMLHttpRequest);
Parse.enableLocalDatastore();
});

Expand Down Expand Up @@ -1082,8 +1080,6 @@ function runTest(controller) {
Parse.initialize('integration');
Parse.CoreManager.set('SERVER_URL', serverURL);
Parse.CoreManager.set('MASTER_KEY', 'notsosecret');
const RESTController = Parse.CoreManager.getRESTController();
RESTController._setXHR(require('xmlhttprequest').XMLHttpRequest);
Parse.enableLocalDatastore();

const numbers = [];
Expand Down
2 changes: 0 additions & 2 deletions integration/test/ParseReactNativeTest.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ const LocalDatastoreController =
const StorageController = require('../../lib/react-native/StorageController.default').default;
const RESTController = require('../../lib/react-native/RESTController').default;

RESTController._setXHR(require('xmlhttprequest').XMLHttpRequest);

describe('Parse React Native', () => {
beforeEach(() => {
// Set up missing controllers and configurations
Expand Down
7 changes: 4 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,7 @@
"idb-keyval": "6.2.1",
"react-native-crypto-js": "1.0.0",
"uuid": "10.0.0",
"ws": "8.18.1",
"xmlhttprequest": "1.8.0"
"ws": "8.18.1"
},
"devDependencies": {
"@babel/core": "7.26.10",
Expand Down
138 changes: 62 additions & 76 deletions src/ParseFile.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,7 @@
/* global XMLHttpRequest, Blob */
/* global Blob */
import CoreManager from './CoreManager';
import type { FullOptions } from './RESTController';
import ParseError from './ParseError';
import XhrWeapp from './Xhr.weapp';

let XHR: any = null;
if (typeof XMLHttpRequest !== 'undefined') {
XHR = XMLHttpRequest;
}
if (process.env.PARSE_BUILD === 'weapp') {
XHR = XhrWeapp;
}

interface Base64 {
base64: string;
Expand Down Expand Up @@ -155,18 +146,29 @@ class ParseFile {
* Data is present if initialized with Byte Array, Base64 or Saved with Uri.
* Data is cleared if saved with File object selected with a file upload control
*
* @param {object} options
* @param {function} [options.progress] callback for download progress
* <pre>
* const parseFile = new Parse.File(name, file);
* parseFile.getData({
* progress: (progressValue, loaded, total) => {
* if (progressValue !== null) {
* // Update the UI using progressValue
* }
* }
* });
* </pre>
* @returns {Promise} Promise that is resolve with base64 data
*/
async getData(): Promise<string> {
async getData(options?: { progress?: () => void }): Promise<string> {
options = options || {};
if (this._data) {
return this._data;
}
if (!this._url) {
throw new Error('Cannot retrieve data for unsaved ParseFile.');
}
const options = {
requestTask: task => (this._requestTask = task),
};
(options as any).requestTask = task => (this._requestTask = task);
const controller = CoreManager.getFileController();
const result = await controller.download(this._url, options);
this._data = result.base64;
Expand Down Expand Up @@ -231,12 +233,12 @@ class ParseFile {
* be used for this request.
* <li>sessionToken: A valid session token, used for making a request on
* behalf of a specific user.
* <li>progress: In Browser only, callback for upload progress. For example:
* <li>progress: callback for upload progress. For example:
* <pre>
* let parseFile = new Parse.File(name, file);
* parseFile.save({
* progress: (progressValue, loaded, total, { type }) => {
* if (type === "upload" && progressValue !== null) {
* progress: (progressValue, loaded, total) => {
* if (progressValue !== null) {
* // Update the UI using progressValue
* }
* }
Expand Down Expand Up @@ -483,58 +485,50 @@ const DefaultController = {
return CoreManager.getRESTController().request('POST', path, data, options);
},

download: function (uri, options) {
if (XHR) {
return this.downloadAjax(uri, options);
} else if (process.env.PARSE_BUILD === 'node') {
return new Promise((resolve, reject) => {
const client = uri.indexOf('https') === 0 ? require('https') : require('http');
const req = client.get(uri, resp => {
resp.setEncoding('base64');
let base64 = '';
resp.on('data', data => (base64 += data));
resp.on('end', () => {
resolve({
base64,
contentType: resp.headers['content-type'],
});
});
});
req.on('abort', () => {
resolve({});
});
req.on('error', reject);
options.requestTask(req);
});
} else {
return Promise.reject('Cannot make a request: No definition of XMLHttpRequest was found.');
}
},

downloadAjax: function (uri: string, options: any) {
return new Promise((resolve, reject) => {
const xhr = new XHR();
xhr.open('GET', uri, true);
xhr.responseType = 'arraybuffer';
xhr.onerror = function (e) {
reject(e);
};
xhr.onreadystatechange = function () {
if (xhr.readyState !== xhr.DONE) {
return;
}
if (!this.response) {
return resolve({});
download: async function (uri, options) {
const controller = new AbortController();
options.requestTask(controller);
const { signal } = controller;
try {
const response = await fetch(uri, { signal });
const reader = response.body.getReader();
const length = +response.headers.get('Content-Length') || 0;
const contentType = response.headers.get('Content-Type');
if (length === 0) {
options.progress?.(null, null, null);
return {
base64: '',
contentType,
};
}
let recieved = 0;
const chunks = [];
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
const bytes = new Uint8Array(this.response);
resolve({
base64: ParseFile.encodeBase64(bytes),
contentType: xhr.getResponseHeader('content-type'),
});
chunks.push(value);
recieved += value?.length || 0;
options.progress?.(recieved / length, recieved, length);
}
const body = new Uint8Array(recieved);
let offset = 0;
for (const chunk of chunks) {
body.set(chunk, offset);
offset += chunk.length;
}
return {
base64: ParseFile.encodeBase64(body),
contentType,
};
options.requestTask(xhr);
xhr.send();
});
} catch (error) {
if (error.name === 'AbortError') {
return {};
} else {
throw error;
}
}
},

deleteFile: function (name: string, options?: FullOptions) {
Expand All @@ -553,21 +547,13 @@ const DefaultController = {
.ajax('DELETE', url, '', headers)
.catch(response => {
// TODO: return JSON object in server
if (!response || response === 'SyntaxError: Unexpected end of JSON input') {
if (!response || response.toString() === 'SyntaxError: Unexpected end of JSON input') {
return Promise.resolve();
} else {
return CoreManager.getRESTController().handleError(response);
}
});
},

_setXHR(xhr: any) {
XHR = xhr;
},

_getXHR() {
return XHR;
},
};

CoreManager.setFileController(DefaultController);
Expand Down
Loading
Loading