From fcc89e9d923893229dae11edb59bd3df082fafad Mon Sep 17 00:00:00 2001 From: David Aurelio Date: Mon, 11 Apr 2016 05:53:41 -0700 Subject: [PATCH] Add support for missing XHR response types Summary:Fixes #6679 This adds support for the missing response types to XMLHttpRequest. Don?t ship this yet. This is completely untested. yolo and stuff. Closes https://github.com/facebook/react-native/pull/6870 Reviewed By: bestander Differential Revision: D3153628 Pulled By: davidaurelio fb-gh-sync-id: 76feae3377bc24b931548a9ac1af07943b1048ac fbshipit-source-id: 76feae3377bc24b931548a9ac1af07943b1048ac --- Libraries/Network/XMLHttpRequestBase.js | 153 +++++++++++++++++---- Libraries/Utilities/__tests__/utf8-test.js | 63 +++++++++ Libraries/Utilities/utf8.js | 91 ++++++++++++ 3 files changed, 280 insertions(+), 27 deletions(-) create mode 100644 Libraries/Utilities/__tests__/utf8-test.js create mode 100644 Libraries/Utilities/utf8.js diff --git a/Libraries/Network/XMLHttpRequestBase.js b/Libraries/Network/XMLHttpRequestBase.js index e82ce515a..972a4db73 100644 --- a/Libraries/Network/XMLHttpRequestBase.js +++ b/Libraries/Network/XMLHttpRequestBase.js @@ -13,7 +13,12 @@ var RCTNetworking = require('RCTNetworking'); var RCTDeviceEventEmitter = require('RCTDeviceEventEmitter'); -var invariant = require('fbjs/lib/invariant'); +const invariant = require('fbjs/lib/invariant'); +const utf8 = require('utf8'); +const warning = require('fbjs/lib/warning'); + +type ResponseType = '' | 'arraybuffer' | 'blob' | 'document' | 'json' | 'text'; +type Response = ?Object | string; const UNSENT = 0; const OPENED = 1; @@ -21,6 +26,15 @@ const HEADERS_RECEIVED = 2; const LOADING = 3; const DONE = 4; +const SUPPORTED_RESPONSE_TYPES = { + arraybuffer: typeof global.ArrayBuffer === 'function', + blob: typeof global.Blob === 'function', + document: false, + json: true, + text: true, + '': true, +}; + /** * Shared base for platform-specific XMLHttpRequest implementations. */ @@ -43,9 +57,7 @@ class XMLHttpRequestBase { upload: any; readyState: number; responseHeaders: ?Object; - responseText: ?string; - response: ?string; - responseType: '' | 'text'; + responseText: string; status: number; timeout: number; responseURL: ?string; @@ -57,12 +69,16 @@ class XMLHttpRequestBase { _requestId: ?number; _subscriptions: [any]; - _method: ?string; - _url: ?string; - _headers: Object; - _sent: boolean; _aborted: boolean; + _cachedResponse: Response; + _hasError: boolean; + _headers: Object; _lowerCaseResponseHeaders: Object; + _method: ?string; + _response: string | ?Object; + _responseType: ResponseType; + _sent: boolean; + _url: ?string; constructor() { this.UNSENT = UNSENT; @@ -82,24 +98,101 @@ class XMLHttpRequestBase { this._aborted = false; } - _reset() { + _reset(): void { this.readyState = this.UNSENT; this.responseHeaders = undefined; this.responseText = ''; - this.response = null; - this.responseType = ''; this.status = 0; delete this.responseURL; this._requestId = null; + this._cachedResponse = undefined; + this._hasError = false; this._headers = {}; + this._responseType = ''; this._sent = false; this._lowerCaseResponseHeaders = {}; this._clearSubscriptions(); } + // $FlowIssue #10784535 + get responseType(): ResponseType { + return this._responseType; + } + + // $FlowIssue #10784535 + set responseType(responseType: ResponseType): void { + if (this.readyState > HEADERS_RECEIVED) { + throw new Error( + "Failed to set the 'responseType' property on 'XMLHttpRequest': The " + + "response type cannot be set if the object's state is LOADING or DONE" + ); + } + if (!SUPPORTED_RESPONSE_TYPES.hasOwnProperty(responseType)) { + warning( + `The provided value '${responseType}' is not a valid 'responseType'.`); + return; + } + + // redboxes early, e.g. for 'arraybuffer' on ios 7 + invariant( + SUPPORTED_RESPONSE_TYPES[responseType] || responseType === 'document', + `The provided value '${responseType}' is unsupported in this environment.` + ); + this._responseType = responseType; + } + + // $FlowIssue #10784535 + get response(): Response { + const {responseType} = this; + if (responseType === '' || responseType === 'text') { + return this.readyState < LOADING || this._hasError + ? '' + : this.responseText; + } + + if (this.readyState !== DONE) { + return null; + } + + if (this._cachedResponse !== undefined) { + return this._cachedResponse; + } + + switch (this.responseType) { + case 'document': + this._cachedResponse = null; + break; + + case 'arraybuffer': + this._cachedResponse = toArrayBuffer( + this.responseText, this.getResponseHeader('content-type') || ''); + break; + + case 'blob': + this._cachedResponse = new global.Blob( + [this.responseText], + {type: this.getResponseHeader('content-type') || ''} + ); + break; + + case 'json': + try { + this._cachedResponse = JSON.parse(this.responseText); + } catch (_) { + this._cachedResponse = null; + } + break; + + default: + this._cachedResponse = null; + } + + return this._cachedResponse; + } + didCreateRequest(requestId: number): void { this._requestId = requestId; this._subscriptions.push(RCTDeviceEventEmitter.addListener( @@ -151,22 +244,7 @@ class XMLHttpRequestBase { } else { this.responseText += responseText; } - switch(this.responseType) { - case '': - case 'text': - this.response = this.responseText; - break; - case 'blob': // whatwg-fetch sets this in Chrome - /* global Blob: true */ - invariant( - typeof Blob === 'function', - `responseType "blob" is only supported on platforms with native Blob support` - ); - this.response = new Blob([this.responseText]); - break; - default: //TODO: Support other types, eg: document, arraybuffer, json - invariant(false, `responseType "${this.responseType}" is unsupported`); - } + this._cachedResponse = undefined; // force lazy recomputation this.setReadyState(this.LOADING); } } @@ -175,6 +253,7 @@ class XMLHttpRequestBase { if (requestId === this._requestId) { if (error) { this.responseText = error; + this._hasError = true; } this._clearSubscriptions(); this._requestId = null; @@ -304,4 +383,24 @@ XMLHttpRequestBase.HEADERS_RECEIVED = HEADERS_RECEIVED; XMLHttpRequestBase.LOADING = LOADING; XMLHttpRequestBase.DONE = DONE; +function toArrayBuffer(text: string, contentType: string): ArrayBuffer { + const {length} = text; + if (length === 0) { + return new ArrayBuffer(0); + } + + const charsetMatch = contentType.match(/;\s*charset=([^;]*)/i); + const charset = charsetMatch ? charsetMatch[1].trim() : 'utf-8'; + + if (/^utf-?8$/i.test(charset)) { + return utf8.encode(text); + } else { //TODO: utf16 / ucs2 / utf32 + const array = new Uint8Array(length); + for (let i = 0; i < length; i++) { + array[i] = text.charCodeAt(i); // Uint8Array automatically masks with 0xff + } + return array.buffer; + } +} + module.exports = XMLHttpRequestBase; diff --git a/Libraries/Utilities/__tests__/utf8-test.js b/Libraries/Utilities/__tests__/utf8-test.js new file mode 100644 index 000000000..dd5553706 --- /dev/null +++ b/Libraries/Utilities/__tests__/utf8-test.js @@ -0,0 +1,63 @@ +/** + * Copyright (c) 2016-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + + 'use strict'; + + jest.autoMockOff(); + + const {encode} = require('../utf8'); + + describe('UTF-8 encoding:', () => { + it('can encode code points < U+80', () => { + const arrayBuffer = encode('\u0000abcDEF\u007f'); + expect(new Uint8Array(arrayBuffer)).toEqual( + new Uint8Array([0x00, 0x61, 0x62, 0x63, 0x44, 0x45, 0x46, 0x7f])); + }); + + it('can encode code points < U+800', () => { + const arrayBuffer = encode('\u0080\u0548\u07ff'); + expect(new Uint8Array(arrayBuffer)).toEqual( + new Uint8Array([0xc2, 0x80, 0xd5, 0x88, 0xdf, 0xbf])); + }); + + it('can encode code points < U+10000', () => { + const arrayBuffer = encode('\u0800\uac48\uffff'); + expect(new Uint8Array(arrayBuffer)).toEqual( + new Uint8Array([0xe0, 0xa0, 0x80, 0xea, 0xb1, 0x88, 0xef, 0xbf, 0xbf])); + }); + + it('can encode code points in the Supplementary Planes (surrogate pairs)', () => { + const arrayBuffer = encode([ + '\ud800\udc00', + '\ud800\ude89', + '\ud83d\ude3b', + '\udbff\udfff' + ].join('')); + expect(new Uint8Array(arrayBuffer)).toEqual( + new Uint8Array([ + 0xf0, 0x90, 0x80, 0x80, + 0xf0, 0x90, 0x8a, 0x89, + 0xf0, 0x9f, 0x98, 0xbb, + 0xf4, 0x8f, 0xbf, 0xbf, + ]) + ); + }); + + it('allows for stray high surrogates', () => { + const arrayBuffer = encode('a\ud8c6b'); + expect(new Uint8Array(arrayBuffer)).toEqual( + new Uint8Array([0x61, 0xed, 0xa3, 0x86, 0x62])); + }); + + it('allows for stray low surrogates', () => { + const arrayBuffer = encode('a\ude19b'); + expect(new Uint8Array(arrayBuffer)).toEqual( + new Uint8Array([0x61, 0xed, 0xb8, 0x99, 0x62])); + }); + }); diff --git a/Libraries/Utilities/utf8.js b/Libraries/Utilities/utf8.js new file mode 100644 index 000000000..130cf5867 --- /dev/null +++ b/Libraries/Utilities/utf8.js @@ -0,0 +1,91 @@ +/** + * Copyright (c) 2016-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @providesModule utf8 + * @flow + */ +'use strict'; + +class ByteVector { + _storage: Uint8Array; + _sizeWritten: number; + + constructor(size) { + this._storage = new Uint8Array(size); + this._sizeWritten = 0; + } + + push(value: number): ByteVector { + const i = this._sizeWritten; + if (i === this._storage.length) { + this._realloc(); + } + this._storage[i] = value; + this._sizeWritten = i + 1; + return this; + } + + getBuffer(): ArrayBuffer { + return this._storage.buffer.slice(0, this._sizeWritten); + } + + _realloc() { + const storage = this._storage; + this._storage = new Uint8Array(align(storage.length * 1.5)); + this._storage.set(storage); + } +} + +/*eslint-disable no-bitwise */ +exports.encode = (string: string): ArrayBuffer => { + const {length} = string; + const bytes = new ByteVector(length); + + // each character / char code is assumed to represent an UTF-16 wchar. + // With the notable exception of surrogate pairs, each wchar represents the + // corresponding unicode code point. + // For an explanation of UTF-8 encoding, read [1] + // For an explanation of UTF-16 surrogate pairs, read [2] + // + // [1] https://en.wikipedia.org/wiki/UTF-8#Description + // [2] https://en.wikipedia.org/wiki/UTF-16#U.2B10000_to_U.2B10FFFF + let nextCodePoint = string.charCodeAt(0); + for (let i = 0; i < length; i++) { + let codePoint = nextCodePoint; + nextCodePoint = string.charCodeAt(i + 1); + + if (codePoint < 0x80) { + bytes.push(codePoint); + } else if (codePoint < 0x800) { + bytes + .push(0xc0 | codePoint >>> 6) + .push(0x80 | codePoint & 0x3f); + } else if (codePoint >>> 10 === 0x36 && nextCodePoint >>> 10 === 0x37) { // high surrogate & low surrogate + codePoint = 0x10000 + (((codePoint & 0x3ff) << 10) | (nextCodePoint & 0x3ff)); + bytes + .push(0xf0 | codePoint >>> 18 & 0x7) + .push(0x80 | codePoint >>> 12 & 0x3f) + .push(0x80 | codePoint >>> 6 & 0x3f) + .push(0x80 | codePoint & 0x3f); + + i += 1; + nextCodePoint = string.charCodeAt(i + 1); + } else { + bytes + .push(0xe0 | codePoint >>> 12) + .push(0x80 | codePoint >>> 6 & 0x3f) + .push(0x80 | codePoint & 0x3f); + } + } + return bytes.getBuffer(); +}; + +// align to multiples of 8 bytes +function align(size: number): number { + return size % 8 ? (Math.floor(size / 8) + 1) << 3 : size; +}