diff --git a/packages/react-native/Libraries/Blob/FileReader.js b/packages/react-native/Libraries/Blob/FileReader.js index 9f516a80d02..ecc00c3e2a1 100644 --- a/packages/react-native/Libraries/Blob/FileReader.js +++ b/packages/react-native/Libraries/Blob/FileReader.js @@ -8,179 +8,13 @@ * @format */ -import type Blob from './Blob'; +import typeof FileReader from './FileReader_old'; -import NativeFileReaderModule from './NativeFileReaderModule'; -import {toByteArray} from 'base64-js'; -import EventTarget from 'event-target-shim'; +// Use a global instead of a flag from ReactNativeFeatureFlags because this will +// be read before apps have a chance to set overrides. +const useBuiltInEventTarget = global.RN$useBuiltInEventTarget?.(); -type ReadyState = - | 0 // EMPTY - | 1 // LOADING - | 2; // DONE - -type ReaderResult = string | ArrayBuffer; - -const READER_EVENTS = [ - 'abort', - 'error', - 'load', - 'loadstart', - 'loadend', - 'progress', -]; - -const EMPTY = 0; -const LOADING = 1; -const DONE = 2; - -class FileReader extends (EventTarget(...READER_EVENTS): typeof EventTarget) { - static EMPTY: number = EMPTY; - static LOADING: number = LOADING; - static DONE: number = DONE; - - EMPTY: number = EMPTY; - LOADING: number = LOADING; - DONE: number = DONE; - - _readyState: ReadyState; - _error: ?Error; - _result: ?ReaderResult; - _aborted: boolean = false; - - constructor() { - super(); - this._reset(); - } - - _reset(): void { - this._readyState = EMPTY; - this._error = null; - this._result = null; - } - - _setReadyState(newState: ReadyState) { - this._readyState = newState; - this.dispatchEvent({type: 'readystatechange'}); - if (newState === DONE) { - if (this._aborted) { - this.dispatchEvent({type: 'abort'}); - } else if (this._error) { - this.dispatchEvent({type: 'error'}); - } else { - this.dispatchEvent({type: 'load'}); - } - this.dispatchEvent({type: 'loadend'}); - } - } - - readAsArrayBuffer(blob: ?Blob): void { - this._aborted = false; - - if (blob == null) { - throw new TypeError( - "Failed to execute 'readAsArrayBuffer' on 'FileReader': parameter 1 is not of type 'Blob'", - ); - } - - NativeFileReaderModule.readAsDataURL(blob.data).then( - (text: string) => { - if (this._aborted) { - return; - } - - const base64 = text.split(',')[1]; - const typedArray = toByteArray(base64); - - this._result = typedArray.buffer; - this._setReadyState(DONE); - }, - error => { - if (this._aborted) { - return; - } - this._error = error; - this._setReadyState(DONE); - }, - ); - } - - readAsDataURL(blob: ?Blob): void { - this._aborted = false; - - if (blob == null) { - throw new TypeError( - "Failed to execute 'readAsDataURL' on 'FileReader': parameter 1 is not of type 'Blob'", - ); - } - - NativeFileReaderModule.readAsDataURL(blob.data).then( - (text: string) => { - if (this._aborted) { - return; - } - this._result = text; - this._setReadyState(DONE); - }, - error => { - if (this._aborted) { - return; - } - this._error = error; - this._setReadyState(DONE); - }, - ); - } - - readAsText(blob: ?Blob, encoding: string = 'UTF-8'): void { - this._aborted = false; - - if (blob == null) { - throw new TypeError( - "Failed to execute 'readAsText' on 'FileReader': parameter 1 is not of type 'Blob'", - ); - } - - NativeFileReaderModule.readAsText(blob.data, encoding).then( - (text: string) => { - if (this._aborted) { - return; - } - this._result = text; - this._setReadyState(DONE); - }, - error => { - if (this._aborted) { - return; - } - this._error = error; - this._setReadyState(DONE); - }, - ); - } - - abort() { - this._aborted = true; - // only call onreadystatechange if there is something to abort, as per spec - if (this._readyState !== EMPTY && this._readyState !== DONE) { - this._reset(); - this._setReadyState(DONE); - } - // Reset again after, in case modified in handler - this._reset(); - } - - get readyState(): ReadyState { - return this._readyState; - } - - get error(): ?Error { - return this._error; - } - - get result(): ?ReaderResult { - return this._result; - } -} - -export default FileReader; +export default (useBuiltInEventTarget + ? // $FlowExpectedError[incompatible-cast] + require('./FileReader_new').default + : require('./FileReader_old').default) as FileReader; diff --git a/packages/react-native/Libraries/Blob/FileReader_new.js b/packages/react-native/Libraries/Blob/FileReader_new.js new file mode 100644 index 00000000000..9cb37e5ca48 --- /dev/null +++ b/packages/react-native/Libraries/Blob/FileReader_new.js @@ -0,0 +1,231 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + */ + +import type {EventCallback} from '../../src/private/webapis/dom/events/EventTarget'; +import type Blob from './Blob'; + +import Event from '../../src/private/webapis/dom/events/Event'; +import { + getEventHandlerAttribute, + setEventHandlerAttribute, +} from '../../src/private/webapis/dom/events/EventHandlerAttributes'; +import EventTarget from '../../src/private/webapis/dom/events/EventTarget'; +import NativeFileReaderModule from './NativeFileReaderModule'; +import {toByteArray} from 'base64-js'; + +type ReadyState = + | 0 // EMPTY + | 1 // LOADING + | 2; // DONE + +type ReaderResult = string | ArrayBuffer; + +const EMPTY = 0; +const LOADING = 1; +const DONE = 2; + +class FileReader extends EventTarget { + static EMPTY: number = EMPTY; + static LOADING: number = LOADING; + static DONE: number = DONE; + + EMPTY: number = EMPTY; + LOADING: number = LOADING; + DONE: number = DONE; + + _readyState: ReadyState; + _error: ?Error; + _result: ?ReaderResult; + _aborted: boolean = false; + + constructor() { + super(); + this._reset(); + } + + _reset(): void { + this._readyState = EMPTY; + this._error = null; + this._result = null; + } + + _setReadyState(newState: ReadyState) { + this._readyState = newState; + this.dispatchEvent(new Event('readystatechange')); + if (newState === DONE) { + if (this._aborted) { + this.dispatchEvent(new Event('abort')); + } else if (this._error) { + this.dispatchEvent(new Event('error')); + } else { + this.dispatchEvent(new Event('load')); + } + this.dispatchEvent(new Event('loadend')); + } + } + + readAsArrayBuffer(blob: ?Blob): void { + this._aborted = false; + + if (blob == null) { + throw new TypeError( + "Failed to execute 'readAsArrayBuffer' on 'FileReader': parameter 1 is not of type 'Blob'", + ); + } + + NativeFileReaderModule.readAsDataURL(blob.data).then( + (text: string) => { + if (this._aborted) { + return; + } + + const base64 = text.split(',')[1]; + const typedArray = toByteArray(base64); + + this._result = typedArray.buffer; + this._setReadyState(DONE); + }, + error => { + if (this._aborted) { + return; + } + this._error = error; + this._setReadyState(DONE); + }, + ); + } + + readAsDataURL(blob: ?Blob): void { + this._aborted = false; + + if (blob == null) { + throw new TypeError( + "Failed to execute 'readAsDataURL' on 'FileReader': parameter 1 is not of type 'Blob'", + ); + } + + NativeFileReaderModule.readAsDataURL(blob.data).then( + (text: string) => { + if (this._aborted) { + return; + } + this._result = text; + this._setReadyState(DONE); + }, + error => { + if (this._aborted) { + return; + } + this._error = error; + this._setReadyState(DONE); + }, + ); + } + + readAsText(blob: ?Blob, encoding: string = 'UTF-8'): void { + this._aborted = false; + + if (blob == null) { + throw new TypeError( + "Failed to execute 'readAsText' on 'FileReader': parameter 1 is not of type 'Blob'", + ); + } + + NativeFileReaderModule.readAsText(blob.data, encoding).then( + (text: string) => { + if (this._aborted) { + return; + } + this._result = text; + this._setReadyState(DONE); + }, + error => { + if (this._aborted) { + return; + } + this._error = error; + this._setReadyState(DONE); + }, + ); + } + + abort() { + this._aborted = true; + // only call onreadystatechange if there is something to abort, as per spec + if (this._readyState !== EMPTY && this._readyState !== DONE) { + this._reset(); + this._setReadyState(DONE); + } + // Reset again after, in case modified in handler + this._reset(); + } + + get readyState(): ReadyState { + return this._readyState; + } + + get error(): ?Error { + return this._error; + } + + get result(): ?ReaderResult { + return this._result; + } + + get onabort(): EventCallback | null { + return getEventHandlerAttribute(this, 'abort'); + } + + set onabort(listener: ?EventCallback) { + setEventHandlerAttribute(this, 'abort', listener); + } + + get onerror(): EventCallback | null { + return getEventHandlerAttribute(this, 'error'); + } + + set onerror(listener: ?EventCallback) { + setEventHandlerAttribute(this, 'error', listener); + } + + get onload(): EventCallback | null { + return getEventHandlerAttribute(this, 'load'); + } + + set onload(listener: ?EventCallback) { + setEventHandlerAttribute(this, 'load', listener); + } + + get onloadstart(): EventCallback | null { + return getEventHandlerAttribute(this, 'loadstart'); + } + + set onloadstart(listener: ?EventCallback) { + setEventHandlerAttribute(this, 'loadstart', listener); + } + + get onloadend(): EventCallback | null { + return getEventHandlerAttribute(this, 'loadend'); + } + + set onloadend(listener: ?EventCallback) { + setEventHandlerAttribute(this, 'loadend', listener); + } + + get onprogress(): EventCallback | null { + return getEventHandlerAttribute(this, 'progress'); + } + + set onprogress(listener: ?EventCallback) { + setEventHandlerAttribute(this, 'progress', listener); + } +} + +export default FileReader; diff --git a/packages/react-native/Libraries/Blob/FileReader_old.js b/packages/react-native/Libraries/Blob/FileReader_old.js new file mode 100644 index 00000000000..9f516a80d02 --- /dev/null +++ b/packages/react-native/Libraries/Blob/FileReader_old.js @@ -0,0 +1,186 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + */ + +import type Blob from './Blob'; + +import NativeFileReaderModule from './NativeFileReaderModule'; +import {toByteArray} from 'base64-js'; +import EventTarget from 'event-target-shim'; + +type ReadyState = + | 0 // EMPTY + | 1 // LOADING + | 2; // DONE + +type ReaderResult = string | ArrayBuffer; + +const READER_EVENTS = [ + 'abort', + 'error', + 'load', + 'loadstart', + 'loadend', + 'progress', +]; + +const EMPTY = 0; +const LOADING = 1; +const DONE = 2; + +class FileReader extends (EventTarget(...READER_EVENTS): typeof EventTarget) { + static EMPTY: number = EMPTY; + static LOADING: number = LOADING; + static DONE: number = DONE; + + EMPTY: number = EMPTY; + LOADING: number = LOADING; + DONE: number = DONE; + + _readyState: ReadyState; + _error: ?Error; + _result: ?ReaderResult; + _aborted: boolean = false; + + constructor() { + super(); + this._reset(); + } + + _reset(): void { + this._readyState = EMPTY; + this._error = null; + this._result = null; + } + + _setReadyState(newState: ReadyState) { + this._readyState = newState; + this.dispatchEvent({type: 'readystatechange'}); + if (newState === DONE) { + if (this._aborted) { + this.dispatchEvent({type: 'abort'}); + } else if (this._error) { + this.dispatchEvent({type: 'error'}); + } else { + this.dispatchEvent({type: 'load'}); + } + this.dispatchEvent({type: 'loadend'}); + } + } + + readAsArrayBuffer(blob: ?Blob): void { + this._aborted = false; + + if (blob == null) { + throw new TypeError( + "Failed to execute 'readAsArrayBuffer' on 'FileReader': parameter 1 is not of type 'Blob'", + ); + } + + NativeFileReaderModule.readAsDataURL(blob.data).then( + (text: string) => { + if (this._aborted) { + return; + } + + const base64 = text.split(',')[1]; + const typedArray = toByteArray(base64); + + this._result = typedArray.buffer; + this._setReadyState(DONE); + }, + error => { + if (this._aborted) { + return; + } + this._error = error; + this._setReadyState(DONE); + }, + ); + } + + readAsDataURL(blob: ?Blob): void { + this._aborted = false; + + if (blob == null) { + throw new TypeError( + "Failed to execute 'readAsDataURL' on 'FileReader': parameter 1 is not of type 'Blob'", + ); + } + + NativeFileReaderModule.readAsDataURL(blob.data).then( + (text: string) => { + if (this._aborted) { + return; + } + this._result = text; + this._setReadyState(DONE); + }, + error => { + if (this._aborted) { + return; + } + this._error = error; + this._setReadyState(DONE); + }, + ); + } + + readAsText(blob: ?Blob, encoding: string = 'UTF-8'): void { + this._aborted = false; + + if (blob == null) { + throw new TypeError( + "Failed to execute 'readAsText' on 'FileReader': parameter 1 is not of type 'Blob'", + ); + } + + NativeFileReaderModule.readAsText(blob.data, encoding).then( + (text: string) => { + if (this._aborted) { + return; + } + this._result = text; + this._setReadyState(DONE); + }, + error => { + if (this._aborted) { + return; + } + this._error = error; + this._setReadyState(DONE); + }, + ); + } + + abort() { + this._aborted = true; + // only call onreadystatechange if there is something to abort, as per spec + if (this._readyState !== EMPTY && this._readyState !== DONE) { + this._reset(); + this._setReadyState(DONE); + } + // Reset again after, in case modified in handler + this._reset(); + } + + get readyState(): ReadyState { + return this._readyState; + } + + get error(): ?Error { + return this._error; + } + + get result(): ?ReaderResult { + return this._result; + } +} + +export default FileReader; diff --git a/packages/react-native/Libraries/Blob/__tests__/FileReader-test.js b/packages/react-native/Libraries/Blob/__tests__/FileReader-test.js index e2321581d6a..aced4832333 100644 --- a/packages/react-native/Libraries/Blob/__tests__/FileReader-test.js +++ b/packages/react-native/Libraries/Blob/__tests__/FileReader-test.js @@ -10,6 +10,9 @@ 'use strict'; +const Blob = require('../Blob').default; +const FileReader = require('../FileReader').default; + jest.unmock('event-target-shim').setMock('../../BatchedBridge/NativeModules', { __esModule: true, default: { @@ -18,39 +21,43 @@ jest.unmock('event-target-shim').setMock('../../BatchedBridge/NativeModules', { }, }); -const Blob = require('../Blob').default; -const FileReader = require('../FileReader').default; - -describe('FileReader', function () { - it('should read blob as text', async () => { - const e = await new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = resolve; - reader.onerror = reject; - reader.readAsText(new Blob()); +[false, true].forEach(enableModern => { + describe(`FileReader (${enableModern ? 'modern' : 'legacy'})`, function () { + beforeAll(() => { + jest.resetModules(); + global.RN$useBuiltInEventTarget = () => enableModern; }); - expect(e.target.result).toBe(''); - }); - it('should read blob as data URL', async () => { - const e = await new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = resolve; - reader.onerror = reject; - reader.readAsDataURL(new Blob()); + it('should read blob as text', async () => { + const e = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = resolve; + reader.onerror = reject; + reader.readAsText(new Blob()); + }); + expect(e.target.result).toBe(''); }); - expect(e.target.result).toBe('data:text/plain;base64,NDI='); - }); - it('should read blob as ArrayBuffer', async () => { - const e = await new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = resolve; - reader.onerror = reject; - reader.readAsArrayBuffer(new Blob()); + it('should read blob as data URL', async () => { + const e = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = resolve; + reader.onerror = reject; + reader.readAsDataURL(new Blob()); + }); + expect(e.target.result).toBe('data:text/plain;base64,NDI='); + }); + + it('should read blob as ArrayBuffer', async () => { + const e = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = resolve; + reader.onerror = reject; + reader.readAsArrayBuffer(new Blob()); + }); + const ab = e.target.result; + expect(ab.byteLength).toBe(2); + expect(new TextDecoder().decode(ab)).toBe('42'); }); - const ab = e.target.result; - expect(ab.byteLength).toBe(2); - expect(new TextDecoder().decode(ab)).toBe('42'); }); }); diff --git a/packages/react-native/Libraries/Network/XMLHttpRequest.js b/packages/react-native/Libraries/Network/XMLHttpRequest.js index 0f6b288529d..14779400066 100644 --- a/packages/react-native/Libraries/Network/XMLHttpRequest.js +++ b/packages/react-native/Libraries/Network/XMLHttpRequest.js @@ -8,691 +8,17 @@ * @flow */ -'use strict'; +import typeof XMLHttpRequest from './XMLHttpRequest_old'; -import type {IPerformanceLogger} from '../Utilities/createPerformanceLogger'; +export type * from './XMLHttpRequest_old'; -import {type EventSubscription} from '../vendor/emitter/EventEmitter'; -import EventTarget from 'event-target-shim'; +// Use a global instead of a flag from ReactNativeFeatureFlags because this will +// be read before apps have a chance to set overrides. +const useBuiltInEventTarget = global.RN$useBuiltInEventTarget?.(); -const BlobManager = require('../Blob/BlobManager').default; -const GlobalPerformanceLogger = require('../Utilities/GlobalPerformanceLogger'); -const RCTNetworking = require('./RCTNetworking').default; -const base64 = require('base64-js'); -const invariant = require('invariant'); - -const DEBUG_NETWORK_SEND_DELAY: false = false; // Set to a number of milliseconds when debugging -const LABEL_FOR_MISSING_URL_FOR_PROFILING = 'Unknown URL'; - -export type NativeResponseType = 'base64' | 'blob' | 'text'; -export type ResponseType = - | '' - | 'arraybuffer' - | 'blob' - | 'document' - | 'json' - | 'text'; -export type Response = ?Object | string; - -type XHRInterceptor = interface { - requestSent(id: number, url: string, method: string, headers: Object): void, - responseReceived( - id: number, - url: string, - status: number, - headers: Object, - ): void, - dataReceived(id: number, data: string): void, - loadingFinished(id: number, encodedDataLength: number): void, - loadingFailed(id: number, error: string): void, -}; - -// The native blob module is optional so inject it here if available. -if (BlobManager.isAvailable) { - BlobManager.addNetworkingHandler(); -} - -const UNSENT = 0; -const OPENED = 1; -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, -}; - -const REQUEST_EVENTS = [ - 'abort', - 'error', - 'load', - 'loadstart', - 'progress', - 'timeout', - 'loadend', -]; - -const XHR_EVENTS = REQUEST_EVENTS.concat('readystatechange'); - -class XMLHttpRequestEventTarget extends (EventTarget( - ...REQUEST_EVENTS, -): typeof EventTarget) { - onload: ?Function; - onloadstart: ?Function; - onprogress: ?Function; - ontimeout: ?Function; - onerror: ?Function; - onabort: ?Function; - onloadend: ?Function; -} - -/** - * Shared base for platform-specific XMLHttpRequest implementations. - */ -class XMLHttpRequest extends (EventTarget(...XHR_EVENTS): typeof EventTarget) { - static UNSENT: number = UNSENT; - static OPENED: number = OPENED; - static HEADERS_RECEIVED: number = HEADERS_RECEIVED; - static LOADING: number = LOADING; - static DONE: number = DONE; - - static _interceptor: ?XHRInterceptor = null; - static _profiling: boolean = false; - - UNSENT: number = UNSENT; - OPENED: number = OPENED; - HEADERS_RECEIVED: number = HEADERS_RECEIVED; - LOADING: number = LOADING; - DONE: number = DONE; - - // EventTarget automatically initializes these to `null`. - onload: ?Function; - onloadstart: ?Function; - onprogress: ?Function; - ontimeout: ?Function; - onerror: ?Function; - onabort: ?Function; - onloadend: ?Function; - onreadystatechange: ?Function; - - readyState: number = UNSENT; - responseHeaders: ?Object; - status: number = 0; - timeout: number = 0; - responseURL: ?string; - withCredentials: boolean = true; - - upload: XMLHttpRequestEventTarget = new XMLHttpRequestEventTarget(); - - _requestId: ?number; - _subscriptions: Array; - - _aborted: boolean = false; - _cachedResponse: Response; - _hasError: boolean = false; - _headers: Object; - _lowerCaseResponseHeaders: Object; - _method: ?string = null; - _perfKey: ?string = null; - _responseType: ResponseType; - _response: string = ''; - _sent: boolean; - _url: ?string = null; - _timedOut: boolean = false; - _trackingName: string = 'unknown'; - _incrementalEvents: boolean = false; - _startTime: ?number = null; - _performanceLogger: IPerformanceLogger = GlobalPerformanceLogger; - - static setInterceptor(interceptor: ?XHRInterceptor) { - XMLHttpRequest._interceptor = interceptor; - } - - static enableProfiling(enableProfiling: boolean): void { - XMLHttpRequest._profiling = enableProfiling; - } - - constructor() { - super(); - this._reset(); - } - - _reset(): void { - this.readyState = this.UNSENT; - this.responseHeaders = undefined; - this.status = 0; - delete this.responseURL; - - this._requestId = null; - - this._cachedResponse = undefined; - this._hasError = false; - this._headers = {}; - this._response = ''; - this._responseType = ''; - this._sent = false; - this._lowerCaseResponseHeaders = {}; - - this._clearSubscriptions(); - this._timedOut = false; - } - - get responseType(): ResponseType { - return this._responseType; - } - - set responseType(responseType: ResponseType): void { - if (this._sent) { - throw new Error( - "Failed to set the 'responseType' property on 'XMLHttpRequest': The " + - 'response type cannot be set after the request has been sent.', - ); - } - if (!SUPPORTED_RESPONSE_TYPES.hasOwnProperty(responseType)) { - console.warn( - `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.`, - ); - - if (responseType === 'blob') { - invariant( - BlobManager.isAvailable, - 'Native module BlobModule is required for blob support', - ); - } - this._responseType = responseType; - } - - get responseText(): string { - if (this._responseType !== '' && this._responseType !== 'text') { - throw new Error( - "The 'responseText' property is only available if 'responseType' " + - `is set to '' or 'text', but it is '${this._responseType}'.`, - ); - } - if (this.readyState < LOADING) { - return ''; - } - return this._response; - } - - get response(): Response { - const {responseType} = this; - if (responseType === '' || responseType === 'text') { - return this.readyState < LOADING || this._hasError ? '' : this._response; - } - - if (this.readyState !== DONE) { - return null; - } - - if (this._cachedResponse !== undefined) { - return this._cachedResponse; - } - - switch (responseType) { - case 'document': - this._cachedResponse = null; - break; - - case 'arraybuffer': - this._cachedResponse = base64.toByteArray(this._response).buffer; - break; - - case 'blob': - if (typeof this._response === 'object' && this._response) { - this._cachedResponse = BlobManager.createFromOptions(this._response); - } else if (this._response === '') { - this._cachedResponse = BlobManager.createFromParts([]); - } else { - throw new Error( - 'Invalid response for blob - expecting object, was ' + - `${typeof this._response}: ${this._response.trim()}`, - ); - } - break; - - case 'json': - try { - this._cachedResponse = JSON.parse(this._response); - } catch (_) { - this._cachedResponse = null; - } - break; - - default: - this._cachedResponse = null; - } - - return this._cachedResponse; - } - - // exposed for testing - __didCreateRequest(requestId: number): void { - this._requestId = requestId; - - XMLHttpRequest._interceptor && - XMLHttpRequest._interceptor.requestSent( - requestId, - this._url || '', - this._method || 'GET', - this._headers, - ); - } - - // exposed for testing - __didUploadProgress( - requestId: number, - progress: number, - total: number, - ): void { - if (requestId === this._requestId) { - this.upload.dispatchEvent({ - type: 'progress', - lengthComputable: true, - loaded: progress, - total, - }); - } - } - - __didReceiveResponse( - requestId: number, - status: number, - responseHeaders: ?Object, - responseURL: ?string, - ): void { - if (requestId === this._requestId) { - this._perfKey != null && - this._performanceLogger.stopTimespan(this._perfKey); - this.status = status; - this.setResponseHeaders(responseHeaders); - this.setReadyState(this.HEADERS_RECEIVED); - if (responseURL || responseURL === '') { - this.responseURL = responseURL; - } else { - delete this.responseURL; - } - - XMLHttpRequest._interceptor && - XMLHttpRequest._interceptor.responseReceived( - requestId, - responseURL || this._url || '', - status, - responseHeaders || {}, - ); - } - } - - __didReceiveData(requestId: number, response: string): void { - if (requestId !== this._requestId) { - return; - } - this._response = response; - this._cachedResponse = undefined; // force lazy recomputation - this.setReadyState(this.LOADING); - - XMLHttpRequest._interceptor && - XMLHttpRequest._interceptor.dataReceived(requestId, response); - } - - __didReceiveIncrementalData( - requestId: number, - responseText: string, - progress: number, - total: number, - ) { - if (requestId !== this._requestId) { - return; - } - if (!this._response) { - this._response = responseText; - } else { - this._response += responseText; - } - - if (XMLHttpRequest._profiling) { - performance.mark( - 'Track:XMLHttpRequest:Incremental Data: ' + this._getMeasureURL(), - ); - } - XMLHttpRequest._interceptor && - XMLHttpRequest._interceptor.dataReceived(requestId, responseText); - - this.setReadyState(this.LOADING); - this.__didReceiveDataProgress(requestId, progress, total); - } - - __didReceiveDataProgress( - requestId: number, - loaded: number, - total: number, - ): void { - if (requestId !== this._requestId) { - return; - } - this.dispatchEvent({ - type: 'progress', - lengthComputable: total >= 0, - loaded, - total, - }); - } - - // exposed for testing - __didCompleteResponse( - requestId: number, - error: string, - timeOutError: boolean, - ): void { - if (requestId === this._requestId) { - if (error) { - if (this._responseType === '' || this._responseType === 'text') { - this._response = error; - } - this._hasError = true; - if (timeOutError) { - this._timedOut = true; - } - } - this._clearSubscriptions(); - this._requestId = null; - this.setReadyState(this.DONE); - if (XMLHttpRequest._profiling && this._startTime != null) { - const start = this._startTime; - performance.measure('Track:XMLHttpRequest:' + this._getMeasureURL(), { - start, - end: performance.now(), - }); - } - if (error) { - XMLHttpRequest._interceptor && - XMLHttpRequest._interceptor.loadingFailed(requestId, error); - } else { - XMLHttpRequest._interceptor && - XMLHttpRequest._interceptor.loadingFinished( - requestId, - this._response.length, - ); - } - } - } - - _clearSubscriptions(): void { - (this._subscriptions || []).forEach(sub => { - if (sub) { - sub.remove(); - } - }); - this._subscriptions = []; - } - - getAllResponseHeaders(): ?string { - if (!this.responseHeaders) { - // according to the spec, return null if no response has been received - return null; - } - - // Assign to non-nullable local variable. - const responseHeaders = this.responseHeaders; - - const unsortedHeaders: Map< - string, - {lowerHeaderName: string, upperHeaderName: string, headerValue: string}, - > = new Map(); - for (const rawHeaderName of Object.keys(responseHeaders)) { - const headerValue = responseHeaders[rawHeaderName]; - const lowerHeaderName = rawHeaderName.toLowerCase(); - const header = unsortedHeaders.get(lowerHeaderName); - if (header) { - header.headerValue += ', ' + headerValue; - unsortedHeaders.set(lowerHeaderName, header); - } else { - unsortedHeaders.set(lowerHeaderName, { - lowerHeaderName, - upperHeaderName: rawHeaderName.toUpperCase(), - headerValue, - }); - } - } - - // Sort in ascending order, with a being less than b if a's name is legacy-uppercased-byte less than b's name. - const sortedHeaders = [...unsortedHeaders.values()].sort((a, b) => { - if (a.upperHeaderName < b.upperHeaderName) { - return -1; - } - if (a.upperHeaderName > b.upperHeaderName) { - return 1; - } - return 0; - }); - - // Combine into single text response. - return ( - sortedHeaders - .map(header => { - return header.lowerHeaderName + ': ' + header.headerValue; - }) - .join('\r\n') + '\r\n' - ); - } - - getResponseHeader(header: string): ?string { - const value = this._lowerCaseResponseHeaders[header.toLowerCase()]; - return value !== undefined ? value : null; - } - - setRequestHeader(header: string, value: any): void { - if (this.readyState !== this.OPENED) { - throw new Error('Request has not been opened'); - } - this._headers[header.toLowerCase()] = String(value); - } - - /** - * Custom extension for tracking origins of request. - */ - setTrackingName(trackingName: string): XMLHttpRequest { - this._trackingName = trackingName; - return this; - } - - /** - * Custom extension for setting a custom performance logger - */ - setPerformanceLogger(performanceLogger: IPerformanceLogger): XMLHttpRequest { - this._performanceLogger = performanceLogger; - return this; - } - - open(method: string, url: string, async: ?boolean): void { - /* Other optional arguments are not supported yet */ - if (this.readyState !== this.UNSENT) { - throw new Error('Cannot open, already sending'); - } - if (async !== undefined && !async) { - // async is default - throw new Error('Synchronous http requests are not supported'); - } - if (!url) { - throw new Error('Cannot load an empty url'); - } - this._method = method.toUpperCase(); - this._url = url; - this._aborted = false; - this.setReadyState(this.OPENED); - } - - send(data: any): void { - if (this.readyState !== this.OPENED) { - throw new Error('Request has not been opened'); - } - if (this._sent) { - throw new Error('Request has already been sent'); - } - this._sent = true; - const incrementalEvents = - this._incrementalEvents || !!this.onreadystatechange || !!this.onprogress; - - this._subscriptions.push( - RCTNetworking.addListener('didSendNetworkData', args => - this.__didUploadProgress(...args), - ), - ); - this._subscriptions.push( - RCTNetworking.addListener('didReceiveNetworkResponse', args => - this.__didReceiveResponse(...args), - ), - ); - this._subscriptions.push( - RCTNetworking.addListener('didReceiveNetworkData', args => - this.__didReceiveData(...args), - ), - ); - this._subscriptions.push( - RCTNetworking.addListener('didReceiveNetworkIncrementalData', args => - this.__didReceiveIncrementalData(...args), - ), - ); - this._subscriptions.push( - RCTNetworking.addListener('didReceiveNetworkDataProgress', args => - this.__didReceiveDataProgress(...args), - ), - ); - this._subscriptions.push( - RCTNetworking.addListener('didCompleteNetworkResponse', args => - this.__didCompleteResponse(...args), - ), - ); - - let nativeResponseType: NativeResponseType = 'text'; - if (this._responseType === 'arraybuffer') { - nativeResponseType = 'base64'; - } - if (this._responseType === 'blob') { - nativeResponseType = 'blob'; - } - - const doSend = () => { - const friendlyName = - this._trackingName !== 'unknown' ? this._trackingName : this._url; - this._perfKey = 'network_XMLHttpRequest_' + String(friendlyName); - this._performanceLogger.startTimespan(this._perfKey); - this._startTime = performance.now(); - invariant( - this._method, - 'XMLHttpRequest method needs to be defined (%s).', - friendlyName, - ); - invariant( - this._url, - 'XMLHttpRequest URL needs to be defined (%s).', - friendlyName, - ); - RCTNetworking.sendRequest( - this._method, - this._trackingName, - this._url, - this._headers, - data, - /* $FlowFixMe(>=0.78.0 site=react_native_android_fb) This issue was found - * when making Flow check .android.js files. */ - nativeResponseType, - incrementalEvents, - this.timeout, - // $FlowFixMe[method-unbinding] added when improving typing for this parameters - this.__didCreateRequest.bind(this), - this.withCredentials, - ); - }; - if (DEBUG_NETWORK_SEND_DELAY) { - setTimeout(doSend, DEBUG_NETWORK_SEND_DELAY); - } else { - doSend(); - } - } - - abort(): void { - this._aborted = true; - if (this._requestId) { - RCTNetworking.abortRequest(this._requestId); - } - // only call onreadystatechange if there is something to abort, - // below logic is per spec - if ( - !( - this.readyState === this.UNSENT || - (this.readyState === this.OPENED && !this._sent) || - this.readyState === this.DONE - ) - ) { - this._reset(); - this.setReadyState(this.DONE); - } - // Reset again after, in case modified in handler - this._reset(); - } - - setResponseHeaders(responseHeaders: ?Object): void { - this.responseHeaders = responseHeaders || null; - const headers = responseHeaders || {}; - this._lowerCaseResponseHeaders = Object.keys(headers).reduce<{ - [string]: any, - }>((lcaseHeaders, headerName) => { - // $FlowFixMe[invalid-computed-prop] - lcaseHeaders[headerName.toLowerCase()] = headers[headerName]; - return lcaseHeaders; - }, {}); - } - - setReadyState(newState: number): void { - this.readyState = newState; - this.dispatchEvent({type: 'readystatechange'}); - if (newState === this.DONE) { - if (this._aborted) { - this.dispatchEvent({type: 'abort'}); - } else if (this._hasError) { - if (this._timedOut) { - this.dispatchEvent({type: 'timeout'}); - } else { - this.dispatchEvent({type: 'error'}); - } - } else { - this.dispatchEvent({type: 'load'}); - } - this.dispatchEvent({type: 'loadend'}); - } - } - - /* global EventListener */ - addEventListener(type: string, listener: EventListener): void { - // If we dont' have a 'readystatechange' event handler, we don't - // have to send repeated LOADING events with incremental updates - // to responseText, which will avoid a bunch of native -> JS - // bridge traffic. - if (type === 'readystatechange' || type === 'progress') { - this._incrementalEvents = true; - } - super.addEventListener(type, listener); - } - - _getMeasureURL(): string { - return ( - this._trackingName ?? this._url ?? LABEL_FOR_MISSING_URL_FOR_PROFILING - ); - } -} - -module.exports = XMLHttpRequest; +module.exports = ( + useBuiltInEventTarget + ? // $FlowExpectedError[incompatible-cast] + require('./XMLHttpRequest_new') + : require('./XMLHttpRequest_old') +) as XMLHttpRequest; diff --git a/packages/react-native/Libraries/Network/XMLHttpRequest_new.js b/packages/react-native/Libraries/Network/XMLHttpRequest_new.js new file mode 100644 index 00000000000..2b3ec300ef5 --- /dev/null +++ b/packages/react-native/Libraries/Network/XMLHttpRequest_new.js @@ -0,0 +1,791 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @flow + */ + +'use strict'; + +import type { + EventCallback, + EventListener, +} from '../../src/private/webapis/dom/events/EventTarget'; +import type {IPerformanceLogger} from '../Utilities/createPerformanceLogger'; + +import Event from '../../src/private/webapis/dom/events/Event'; +import { + getEventHandlerAttribute, + setEventHandlerAttribute, +} from '../../src/private/webapis/dom/events/EventHandlerAttributes'; +import EventTarget from '../../src/private/webapis/dom/events/EventTarget'; +import {dispatchTrustedEvent} from '../../src/private/webapis/dom/events/internals/EventTargetInternals'; +import ProgressEvent from '../../src/private/webapis/xhr/events/ProgressEvent'; +import {type EventSubscription} from '../vendor/emitter/EventEmitter'; + +const BlobManager = require('../Blob/BlobManager').default; +const GlobalPerformanceLogger = require('../Utilities/GlobalPerformanceLogger'); +const RCTNetworking = require('./RCTNetworking').default; +const base64 = require('base64-js'); +const invariant = require('invariant'); + +const DEBUG_NETWORK_SEND_DELAY: false = false; // Set to a number of milliseconds when debugging +const LABEL_FOR_MISSING_URL_FOR_PROFILING = 'Unknown URL'; + +export type NativeResponseType = 'base64' | 'blob' | 'text'; +export type ResponseType = + | '' + | 'arraybuffer' + | 'blob' + | 'document' + | 'json' + | 'text'; +export type Response = ?Object | string; + +type XHRInterceptor = interface { + requestSent(id: number, url: string, method: string, headers: Object): void, + responseReceived( + id: number, + url: string, + status: number, + headers: Object, + ): void, + dataReceived(id: number, data: string): void, + loadingFinished(id: number, encodedDataLength: number): void, + loadingFailed(id: number, error: string): void, +}; + +// The native blob module is optional so inject it here if available. +if (BlobManager.isAvailable) { + BlobManager.addNetworkingHandler(); +} + +const UNSENT = 0; +const OPENED = 1; +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, +}; + +class XMLHttpRequestEventTarget extends EventTarget { + get onload(): EventCallback | null { + return getEventHandlerAttribute(this, 'load'); + } + set onload(listener: ?EventCallback) { + setEventHandlerAttribute(this, 'load', listener); + } + get onloadstart(): EventCallback | null { + return getEventHandlerAttribute(this, 'loadstart'); + } + set onloadstart(listener: ?EventCallback) { + setEventHandlerAttribute(this, 'loadstart', listener); + } + get onprogress(): EventCallback | null { + return getEventHandlerAttribute(this, 'progress'); + } + set onprogress(listener: ?EventCallback) { + setEventHandlerAttribute(this, 'progress', listener); + } + get ontimeout(): EventCallback | null { + return getEventHandlerAttribute(this, 'timeout'); + } + set ontimeout(listener: ?EventCallback) { + setEventHandlerAttribute(this, 'timeout', listener); + } + get onerror(): EventCallback | null { + return getEventHandlerAttribute(this, 'error'); + } + set onerror(listener: ?EventCallback) { + setEventHandlerAttribute(this, 'error', listener); + } + get onabort(): EventCallback | null { + return getEventHandlerAttribute(this, 'abort'); + } + set onabort(listener: ?EventCallback) { + setEventHandlerAttribute(this, 'abort', listener); + } + get onloadend(): EventCallback | null { + return getEventHandlerAttribute(this, 'loadend'); + } + set onloadend(listener: ?EventCallback) { + setEventHandlerAttribute(this, 'loadend', listener); + } +} + +/** + * Shared base for platform-specific XMLHttpRequest implementations. + */ +class XMLHttpRequest extends EventTarget { + static UNSENT: number = UNSENT; + static OPENED: number = OPENED; + static HEADERS_RECEIVED: number = HEADERS_RECEIVED; + static LOADING: number = LOADING; + static DONE: number = DONE; + + static _interceptor: ?XHRInterceptor = null; + static _profiling: boolean = false; + + UNSENT: number = UNSENT; + OPENED: number = OPENED; + HEADERS_RECEIVED: number = HEADERS_RECEIVED; + LOADING: number = LOADING; + DONE: number = DONE; + + readyState: number = UNSENT; + responseHeaders: ?Object; + status: number = 0; + timeout: number = 0; + responseURL: ?string; + withCredentials: boolean = true; + + upload: XMLHttpRequestEventTarget = new XMLHttpRequestEventTarget(); + + _requestId: ?number; + _subscriptions: Array; + + _aborted: boolean = false; + _cachedResponse: Response; + _hasError: boolean = false; + _headers: Object; + _lowerCaseResponseHeaders: Object; + _method: ?string = null; + _perfKey: ?string = null; + _responseType: ResponseType; + _response: string = ''; + _sent: boolean; + _url: ?string = null; + _timedOut: boolean = false; + _trackingName: string = 'unknown'; + _incrementalEvents: boolean = false; + _startTime: ?number = null; + _performanceLogger: IPerformanceLogger = GlobalPerformanceLogger; + + static setInterceptor(interceptor: ?XHRInterceptor) { + XMLHttpRequest._interceptor = interceptor; + } + + static enableProfiling(enableProfiling: boolean): void { + XMLHttpRequest._profiling = enableProfiling; + } + + constructor() { + super(); + this._reset(); + } + + _reset(): void { + this.readyState = this.UNSENT; + this.responseHeaders = undefined; + this.status = 0; + delete this.responseURL; + + this._requestId = null; + + this._cachedResponse = undefined; + this._hasError = false; + this._headers = {}; + this._response = ''; + this._responseType = ''; + this._sent = false; + this._lowerCaseResponseHeaders = {}; + + this._clearSubscriptions(); + this._timedOut = false; + } + + get responseType(): ResponseType { + return this._responseType; + } + + set responseType(responseType: ResponseType): void { + if (this._sent) { + throw new Error( + "Failed to set the 'responseType' property on 'XMLHttpRequest': The " + + 'response type cannot be set after the request has been sent.', + ); + } + if (!SUPPORTED_RESPONSE_TYPES.hasOwnProperty(responseType)) { + console.warn( + `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.`, + ); + + if (responseType === 'blob') { + invariant( + BlobManager.isAvailable, + 'Native module BlobModule is required for blob support', + ); + } + this._responseType = responseType; + } + + get responseText(): string { + if (this._responseType !== '' && this._responseType !== 'text') { + throw new Error( + "The 'responseText' property is only available if 'responseType' " + + `is set to '' or 'text', but it is '${this._responseType}'.`, + ); + } + if (this.readyState < LOADING) { + return ''; + } + return this._response; + } + + get response(): Response { + const {responseType} = this; + if (responseType === '' || responseType === 'text') { + return this.readyState < LOADING || this._hasError ? '' : this._response; + } + + if (this.readyState !== DONE) { + return null; + } + + if (this._cachedResponse !== undefined) { + return this._cachedResponse; + } + + switch (responseType) { + case 'document': + this._cachedResponse = null; + break; + + case 'arraybuffer': + this._cachedResponse = base64.toByteArray(this._response).buffer; + break; + + case 'blob': + if (typeof this._response === 'object' && this._response) { + this._cachedResponse = BlobManager.createFromOptions(this._response); + } else if (this._response === '') { + this._cachedResponse = BlobManager.createFromParts([]); + } else { + throw new Error( + 'Invalid response for blob - expecting object, was ' + + `${typeof this._response}: ${this._response.trim()}`, + ); + } + break; + + case 'json': + try { + this._cachedResponse = JSON.parse(this._response); + } catch (_) { + this._cachedResponse = null; + } + break; + + default: + this._cachedResponse = null; + } + + return this._cachedResponse; + } + + // exposed for testing + __didCreateRequest(requestId: number): void { + this._requestId = requestId; + + XMLHttpRequest._interceptor && + XMLHttpRequest._interceptor.requestSent( + requestId, + this._url || '', + this._method || 'GET', + this._headers, + ); + } + + // exposed for testing + __didUploadProgress( + requestId: number, + progress: number, + total: number, + ): void { + if (requestId === this._requestId) { + dispatchTrustedEvent( + this.upload, + new ProgressEvent('progress', { + lengthComputable: true, + loaded: progress, + total, + }), + ); + } + } + + __didReceiveResponse( + requestId: number, + status: number, + responseHeaders: ?Object, + responseURL: ?string, + ): void { + if (requestId === this._requestId) { + this._perfKey != null && + this._performanceLogger.stopTimespan(this._perfKey); + this.status = status; + this.setResponseHeaders(responseHeaders); + this.setReadyState(this.HEADERS_RECEIVED); + if (responseURL || responseURL === '') { + this.responseURL = responseURL; + } else { + delete this.responseURL; + } + + XMLHttpRequest._interceptor && + XMLHttpRequest._interceptor.responseReceived( + requestId, + responseURL || this._url || '', + status, + responseHeaders || {}, + ); + } + } + + __didReceiveData(requestId: number, response: string): void { + if (requestId !== this._requestId) { + return; + } + this._response = response; + this._cachedResponse = undefined; // force lazy recomputation + this.setReadyState(this.LOADING); + + XMLHttpRequest._interceptor && + XMLHttpRequest._interceptor.dataReceived(requestId, response); + } + + __didReceiveIncrementalData( + requestId: number, + responseText: string, + progress: number, + total: number, + ) { + if (requestId !== this._requestId) { + return; + } + if (!this._response) { + this._response = responseText; + } else { + this._response += responseText; + } + + if (XMLHttpRequest._profiling) { + performance.mark( + 'Track:XMLHttpRequest:Incremental Data: ' + this._getMeasureURL(), + ); + } + XMLHttpRequest._interceptor && + XMLHttpRequest._interceptor.dataReceived(requestId, responseText); + + this.setReadyState(this.LOADING); + this.__didReceiveDataProgress(requestId, progress, total); + } + + __didReceiveDataProgress( + requestId: number, + loaded: number, + total: number, + ): void { + if (requestId !== this._requestId) { + return; + } + dispatchTrustedEvent( + this, + new ProgressEvent('progress', { + lengthComputable: total >= 0, + loaded, + total, + }), + ); + } + + // exposed for testing + __didCompleteResponse( + requestId: number, + error: string, + timeOutError: boolean, + ): void { + if (requestId === this._requestId) { + if (error) { + if (this._responseType === '' || this._responseType === 'text') { + this._response = error; + } + this._hasError = true; + if (timeOutError) { + this._timedOut = true; + } + } + this._clearSubscriptions(); + this._requestId = null; + this.setReadyState(this.DONE); + if (XMLHttpRequest._profiling && this._startTime != null) { + const start = this._startTime; + performance.measure('Track:XMLHttpRequest:' + this._getMeasureURL(), { + start, + end: performance.now(), + }); + } + if (error) { + XMLHttpRequest._interceptor && + XMLHttpRequest._interceptor.loadingFailed(requestId, error); + } else { + XMLHttpRequest._interceptor && + XMLHttpRequest._interceptor.loadingFinished( + requestId, + this._response.length, + ); + } + } + } + + _clearSubscriptions(): void { + (this._subscriptions || []).forEach(sub => { + if (sub) { + sub.remove(); + } + }); + this._subscriptions = []; + } + + getAllResponseHeaders(): ?string { + if (!this.responseHeaders) { + // according to the spec, return null if no response has been received + return null; + } + + // Assign to non-nullable local variable. + const responseHeaders = this.responseHeaders; + + const unsortedHeaders: Map< + string, + {lowerHeaderName: string, upperHeaderName: string, headerValue: string}, + > = new Map(); + for (const rawHeaderName of Object.keys(responseHeaders)) { + const headerValue = responseHeaders[rawHeaderName]; + const lowerHeaderName = rawHeaderName.toLowerCase(); + const header = unsortedHeaders.get(lowerHeaderName); + if (header) { + header.headerValue += ', ' + headerValue; + unsortedHeaders.set(lowerHeaderName, header); + } else { + unsortedHeaders.set(lowerHeaderName, { + lowerHeaderName, + upperHeaderName: rawHeaderName.toUpperCase(), + headerValue, + }); + } + } + + // Sort in ascending order, with a being less than b if a's name is legacy-uppercased-byte less than b's name. + const sortedHeaders = [...unsortedHeaders.values()].sort((a, b) => { + if (a.upperHeaderName < b.upperHeaderName) { + return -1; + } + if (a.upperHeaderName > b.upperHeaderName) { + return 1; + } + return 0; + }); + + // Combine into single text response. + return ( + sortedHeaders + .map(header => { + return header.lowerHeaderName + ': ' + header.headerValue; + }) + .join('\r\n') + '\r\n' + ); + } + + getResponseHeader(header: string): ?string { + const value = this._lowerCaseResponseHeaders[header.toLowerCase()]; + return value !== undefined ? value : null; + } + + setRequestHeader(header: string, value: any): void { + if (this.readyState !== this.OPENED) { + throw new Error('Request has not been opened'); + } + this._headers[header.toLowerCase()] = String(value); + } + + /** + * Custom extension for tracking origins of request. + */ + setTrackingName(trackingName: string): XMLHttpRequest { + this._trackingName = trackingName; + return this; + } + + /** + * Custom extension for setting a custom performance logger + */ + setPerformanceLogger(performanceLogger: IPerformanceLogger): XMLHttpRequest { + this._performanceLogger = performanceLogger; + return this; + } + + open(method: string, url: string, async: ?boolean): void { + /* Other optional arguments are not supported yet */ + if (this.readyState !== this.UNSENT) { + throw new Error('Cannot open, already sending'); + } + if (async !== undefined && !async) { + // async is default + throw new Error('Synchronous http requests are not supported'); + } + if (!url) { + throw new Error('Cannot load an empty url'); + } + this._method = method.toUpperCase(); + this._url = url; + this._aborted = false; + this.setReadyState(this.OPENED); + } + + send(data: any): void { + if (this.readyState !== this.OPENED) { + throw new Error('Request has not been opened'); + } + if (this._sent) { + throw new Error('Request has already been sent'); + } + this._sent = true; + const incrementalEvents = + this._incrementalEvents || !!this.onreadystatechange || !!this.onprogress; + + this._subscriptions.push( + RCTNetworking.addListener('didSendNetworkData', args => + this.__didUploadProgress(...args), + ), + ); + this._subscriptions.push( + RCTNetworking.addListener('didReceiveNetworkResponse', args => + this.__didReceiveResponse(...args), + ), + ); + this._subscriptions.push( + RCTNetworking.addListener('didReceiveNetworkData', args => + this.__didReceiveData(...args), + ), + ); + this._subscriptions.push( + RCTNetworking.addListener('didReceiveNetworkIncrementalData', args => + this.__didReceiveIncrementalData(...args), + ), + ); + this._subscriptions.push( + RCTNetworking.addListener('didReceiveNetworkDataProgress', args => + this.__didReceiveDataProgress(...args), + ), + ); + this._subscriptions.push( + RCTNetworking.addListener('didCompleteNetworkResponse', args => + this.__didCompleteResponse(...args), + ), + ); + + let nativeResponseType: NativeResponseType = 'text'; + if (this._responseType === 'arraybuffer') { + nativeResponseType = 'base64'; + } + if (this._responseType === 'blob') { + nativeResponseType = 'blob'; + } + + const doSend = () => { + const friendlyName = + this._trackingName !== 'unknown' ? this._trackingName : this._url; + this._perfKey = 'network_XMLHttpRequest_' + String(friendlyName); + this._performanceLogger.startTimespan(this._perfKey); + this._startTime = performance.now(); + invariant( + this._method, + 'XMLHttpRequest method needs to be defined (%s).', + friendlyName, + ); + invariant( + this._url, + 'XMLHttpRequest URL needs to be defined (%s).', + friendlyName, + ); + RCTNetworking.sendRequest( + this._method, + this._trackingName, + this._url, + this._headers, + data, + /* $FlowFixMe(>=0.78.0 site=react_native_android_fb) This issue was found + * when making Flow check .android.js files. */ + nativeResponseType, + incrementalEvents, + this.timeout, + // $FlowFixMe[method-unbinding] added when improving typing for this parameters + this.__didCreateRequest.bind(this), + this.withCredentials, + ); + }; + if (DEBUG_NETWORK_SEND_DELAY) { + setTimeout(doSend, DEBUG_NETWORK_SEND_DELAY); + } else { + doSend(); + } + } + + abort(): void { + this._aborted = true; + if (this._requestId) { + RCTNetworking.abortRequest(this._requestId); + } + // only call onreadystatechange if there is something to abort, + // below logic is per spec + if ( + !( + this.readyState === this.UNSENT || + (this.readyState === this.OPENED && !this._sent) || + this.readyState === this.DONE + ) + ) { + this._reset(); + this.setReadyState(this.DONE); + } + // Reset again after, in case modified in handler + this._reset(); + } + + setResponseHeaders(responseHeaders: ?Object): void { + this.responseHeaders = responseHeaders || null; + const headers = responseHeaders || {}; + this._lowerCaseResponseHeaders = Object.keys(headers).reduce<{ + [string]: any, + }>((lcaseHeaders, headerName) => { + // $FlowFixMe[invalid-computed-prop] + lcaseHeaders[headerName.toLowerCase()] = headers[headerName]; + return lcaseHeaders; + }, {}); + } + + setReadyState(newState: number): void { + this.readyState = newState; + dispatchTrustedEvent(this, new Event('readystatechange')); + if (newState === this.DONE) { + if (this._aborted) { + dispatchTrustedEvent(this, new Event('abort')); + } else if (this._hasError) { + if (this._timedOut) { + dispatchTrustedEvent(this, new Event('timeout')); + } else { + dispatchTrustedEvent(this, new Event('error')); + } + } else { + dispatchTrustedEvent(this, new Event('load')); + } + dispatchTrustedEvent(this, new Event('loadend')); + } + } + + addEventListener(type: string, listener: EventListener | null): void { + // If we dont' have a 'readystatechange' event handler, we don't + // have to send repeated LOADING events with incremental updates + // to responseText, which will avoid a bunch of native -> JS + // bridge traffic. + if (type === 'readystatechange' || type === 'progress') { + this._incrementalEvents = true; + } + super.addEventListener(type, listener); + } + + _getMeasureURL(): string { + return ( + this._trackingName ?? this._url ?? LABEL_FOR_MISSING_URL_FOR_PROFILING + ); + } + + /* + * `on` event handling (without JS prototype magic). + */ + + get onabort(): EventCallback | null { + return getEventHandlerAttribute(this, 'abort'); + } + + set onabort(listener: ?EventCallback) { + setEventHandlerAttribute(this, 'abort', listener); + } + + get onerror(): EventCallback | null { + return getEventHandlerAttribute(this, 'error'); + } + + set onerror(listener: ?EventCallback) { + setEventHandlerAttribute(this, 'error', listener); + } + + get onload(): EventCallback | null { + return getEventHandlerAttribute(this, 'load'); + } + + set onload(listener: ?EventCallback) { + setEventHandlerAttribute(this, 'load', listener); + } + + get onloadstart(): EventCallback | null { + return getEventHandlerAttribute(this, 'loadstart'); + } + + set onloadstart(listener: ?EventCallback) { + setEventHandlerAttribute(this, 'loadstart', listener); + } + + get onprogress(): EventCallback | null { + return getEventHandlerAttribute(this, 'progress'); + } + + set onprogress(listener: ?EventCallback) { + setEventHandlerAttribute(this, 'progress', listener); + } + + get ontimeout(): EventCallback | null { + return getEventHandlerAttribute(this, 'timeout'); + } + + set ontimeout(listener: ?EventCallback) { + setEventHandlerAttribute(this, 'timeout', listener); + } + + get onloadend(): EventCallback | null { + return getEventHandlerAttribute(this, 'loadend'); + } + + set onloadend(listener: ?EventCallback) { + setEventHandlerAttribute(this, 'loadend', listener); + } + + get onreadystatechange(): EventCallback | null { + return getEventHandlerAttribute(this, 'readystatechange'); + } + + set onreadystatechange(listener: ?EventCallback) { + setEventHandlerAttribute(this, 'readystatechange', listener); + } +} + +module.exports = XMLHttpRequest; diff --git a/packages/react-native/Libraries/Network/XMLHttpRequest_old.js b/packages/react-native/Libraries/Network/XMLHttpRequest_old.js new file mode 100644 index 00000000000..0f6b288529d --- /dev/null +++ b/packages/react-native/Libraries/Network/XMLHttpRequest_old.js @@ -0,0 +1,698 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @flow + */ + +'use strict'; + +import type {IPerformanceLogger} from '../Utilities/createPerformanceLogger'; + +import {type EventSubscription} from '../vendor/emitter/EventEmitter'; +import EventTarget from 'event-target-shim'; + +const BlobManager = require('../Blob/BlobManager').default; +const GlobalPerformanceLogger = require('../Utilities/GlobalPerformanceLogger'); +const RCTNetworking = require('./RCTNetworking').default; +const base64 = require('base64-js'); +const invariant = require('invariant'); + +const DEBUG_NETWORK_SEND_DELAY: false = false; // Set to a number of milliseconds when debugging +const LABEL_FOR_MISSING_URL_FOR_PROFILING = 'Unknown URL'; + +export type NativeResponseType = 'base64' | 'blob' | 'text'; +export type ResponseType = + | '' + | 'arraybuffer' + | 'blob' + | 'document' + | 'json' + | 'text'; +export type Response = ?Object | string; + +type XHRInterceptor = interface { + requestSent(id: number, url: string, method: string, headers: Object): void, + responseReceived( + id: number, + url: string, + status: number, + headers: Object, + ): void, + dataReceived(id: number, data: string): void, + loadingFinished(id: number, encodedDataLength: number): void, + loadingFailed(id: number, error: string): void, +}; + +// The native blob module is optional so inject it here if available. +if (BlobManager.isAvailable) { + BlobManager.addNetworkingHandler(); +} + +const UNSENT = 0; +const OPENED = 1; +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, +}; + +const REQUEST_EVENTS = [ + 'abort', + 'error', + 'load', + 'loadstart', + 'progress', + 'timeout', + 'loadend', +]; + +const XHR_EVENTS = REQUEST_EVENTS.concat('readystatechange'); + +class XMLHttpRequestEventTarget extends (EventTarget( + ...REQUEST_EVENTS, +): typeof EventTarget) { + onload: ?Function; + onloadstart: ?Function; + onprogress: ?Function; + ontimeout: ?Function; + onerror: ?Function; + onabort: ?Function; + onloadend: ?Function; +} + +/** + * Shared base for platform-specific XMLHttpRequest implementations. + */ +class XMLHttpRequest extends (EventTarget(...XHR_EVENTS): typeof EventTarget) { + static UNSENT: number = UNSENT; + static OPENED: number = OPENED; + static HEADERS_RECEIVED: number = HEADERS_RECEIVED; + static LOADING: number = LOADING; + static DONE: number = DONE; + + static _interceptor: ?XHRInterceptor = null; + static _profiling: boolean = false; + + UNSENT: number = UNSENT; + OPENED: number = OPENED; + HEADERS_RECEIVED: number = HEADERS_RECEIVED; + LOADING: number = LOADING; + DONE: number = DONE; + + // EventTarget automatically initializes these to `null`. + onload: ?Function; + onloadstart: ?Function; + onprogress: ?Function; + ontimeout: ?Function; + onerror: ?Function; + onabort: ?Function; + onloadend: ?Function; + onreadystatechange: ?Function; + + readyState: number = UNSENT; + responseHeaders: ?Object; + status: number = 0; + timeout: number = 0; + responseURL: ?string; + withCredentials: boolean = true; + + upload: XMLHttpRequestEventTarget = new XMLHttpRequestEventTarget(); + + _requestId: ?number; + _subscriptions: Array; + + _aborted: boolean = false; + _cachedResponse: Response; + _hasError: boolean = false; + _headers: Object; + _lowerCaseResponseHeaders: Object; + _method: ?string = null; + _perfKey: ?string = null; + _responseType: ResponseType; + _response: string = ''; + _sent: boolean; + _url: ?string = null; + _timedOut: boolean = false; + _trackingName: string = 'unknown'; + _incrementalEvents: boolean = false; + _startTime: ?number = null; + _performanceLogger: IPerformanceLogger = GlobalPerformanceLogger; + + static setInterceptor(interceptor: ?XHRInterceptor) { + XMLHttpRequest._interceptor = interceptor; + } + + static enableProfiling(enableProfiling: boolean): void { + XMLHttpRequest._profiling = enableProfiling; + } + + constructor() { + super(); + this._reset(); + } + + _reset(): void { + this.readyState = this.UNSENT; + this.responseHeaders = undefined; + this.status = 0; + delete this.responseURL; + + this._requestId = null; + + this._cachedResponse = undefined; + this._hasError = false; + this._headers = {}; + this._response = ''; + this._responseType = ''; + this._sent = false; + this._lowerCaseResponseHeaders = {}; + + this._clearSubscriptions(); + this._timedOut = false; + } + + get responseType(): ResponseType { + return this._responseType; + } + + set responseType(responseType: ResponseType): void { + if (this._sent) { + throw new Error( + "Failed to set the 'responseType' property on 'XMLHttpRequest': The " + + 'response type cannot be set after the request has been sent.', + ); + } + if (!SUPPORTED_RESPONSE_TYPES.hasOwnProperty(responseType)) { + console.warn( + `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.`, + ); + + if (responseType === 'blob') { + invariant( + BlobManager.isAvailable, + 'Native module BlobModule is required for blob support', + ); + } + this._responseType = responseType; + } + + get responseText(): string { + if (this._responseType !== '' && this._responseType !== 'text') { + throw new Error( + "The 'responseText' property is only available if 'responseType' " + + `is set to '' or 'text', but it is '${this._responseType}'.`, + ); + } + if (this.readyState < LOADING) { + return ''; + } + return this._response; + } + + get response(): Response { + const {responseType} = this; + if (responseType === '' || responseType === 'text') { + return this.readyState < LOADING || this._hasError ? '' : this._response; + } + + if (this.readyState !== DONE) { + return null; + } + + if (this._cachedResponse !== undefined) { + return this._cachedResponse; + } + + switch (responseType) { + case 'document': + this._cachedResponse = null; + break; + + case 'arraybuffer': + this._cachedResponse = base64.toByteArray(this._response).buffer; + break; + + case 'blob': + if (typeof this._response === 'object' && this._response) { + this._cachedResponse = BlobManager.createFromOptions(this._response); + } else if (this._response === '') { + this._cachedResponse = BlobManager.createFromParts([]); + } else { + throw new Error( + 'Invalid response for blob - expecting object, was ' + + `${typeof this._response}: ${this._response.trim()}`, + ); + } + break; + + case 'json': + try { + this._cachedResponse = JSON.parse(this._response); + } catch (_) { + this._cachedResponse = null; + } + break; + + default: + this._cachedResponse = null; + } + + return this._cachedResponse; + } + + // exposed for testing + __didCreateRequest(requestId: number): void { + this._requestId = requestId; + + XMLHttpRequest._interceptor && + XMLHttpRequest._interceptor.requestSent( + requestId, + this._url || '', + this._method || 'GET', + this._headers, + ); + } + + // exposed for testing + __didUploadProgress( + requestId: number, + progress: number, + total: number, + ): void { + if (requestId === this._requestId) { + this.upload.dispatchEvent({ + type: 'progress', + lengthComputable: true, + loaded: progress, + total, + }); + } + } + + __didReceiveResponse( + requestId: number, + status: number, + responseHeaders: ?Object, + responseURL: ?string, + ): void { + if (requestId === this._requestId) { + this._perfKey != null && + this._performanceLogger.stopTimespan(this._perfKey); + this.status = status; + this.setResponseHeaders(responseHeaders); + this.setReadyState(this.HEADERS_RECEIVED); + if (responseURL || responseURL === '') { + this.responseURL = responseURL; + } else { + delete this.responseURL; + } + + XMLHttpRequest._interceptor && + XMLHttpRequest._interceptor.responseReceived( + requestId, + responseURL || this._url || '', + status, + responseHeaders || {}, + ); + } + } + + __didReceiveData(requestId: number, response: string): void { + if (requestId !== this._requestId) { + return; + } + this._response = response; + this._cachedResponse = undefined; // force lazy recomputation + this.setReadyState(this.LOADING); + + XMLHttpRequest._interceptor && + XMLHttpRequest._interceptor.dataReceived(requestId, response); + } + + __didReceiveIncrementalData( + requestId: number, + responseText: string, + progress: number, + total: number, + ) { + if (requestId !== this._requestId) { + return; + } + if (!this._response) { + this._response = responseText; + } else { + this._response += responseText; + } + + if (XMLHttpRequest._profiling) { + performance.mark( + 'Track:XMLHttpRequest:Incremental Data: ' + this._getMeasureURL(), + ); + } + XMLHttpRequest._interceptor && + XMLHttpRequest._interceptor.dataReceived(requestId, responseText); + + this.setReadyState(this.LOADING); + this.__didReceiveDataProgress(requestId, progress, total); + } + + __didReceiveDataProgress( + requestId: number, + loaded: number, + total: number, + ): void { + if (requestId !== this._requestId) { + return; + } + this.dispatchEvent({ + type: 'progress', + lengthComputable: total >= 0, + loaded, + total, + }); + } + + // exposed for testing + __didCompleteResponse( + requestId: number, + error: string, + timeOutError: boolean, + ): void { + if (requestId === this._requestId) { + if (error) { + if (this._responseType === '' || this._responseType === 'text') { + this._response = error; + } + this._hasError = true; + if (timeOutError) { + this._timedOut = true; + } + } + this._clearSubscriptions(); + this._requestId = null; + this.setReadyState(this.DONE); + if (XMLHttpRequest._profiling && this._startTime != null) { + const start = this._startTime; + performance.measure('Track:XMLHttpRequest:' + this._getMeasureURL(), { + start, + end: performance.now(), + }); + } + if (error) { + XMLHttpRequest._interceptor && + XMLHttpRequest._interceptor.loadingFailed(requestId, error); + } else { + XMLHttpRequest._interceptor && + XMLHttpRequest._interceptor.loadingFinished( + requestId, + this._response.length, + ); + } + } + } + + _clearSubscriptions(): void { + (this._subscriptions || []).forEach(sub => { + if (sub) { + sub.remove(); + } + }); + this._subscriptions = []; + } + + getAllResponseHeaders(): ?string { + if (!this.responseHeaders) { + // according to the spec, return null if no response has been received + return null; + } + + // Assign to non-nullable local variable. + const responseHeaders = this.responseHeaders; + + const unsortedHeaders: Map< + string, + {lowerHeaderName: string, upperHeaderName: string, headerValue: string}, + > = new Map(); + for (const rawHeaderName of Object.keys(responseHeaders)) { + const headerValue = responseHeaders[rawHeaderName]; + const lowerHeaderName = rawHeaderName.toLowerCase(); + const header = unsortedHeaders.get(lowerHeaderName); + if (header) { + header.headerValue += ', ' + headerValue; + unsortedHeaders.set(lowerHeaderName, header); + } else { + unsortedHeaders.set(lowerHeaderName, { + lowerHeaderName, + upperHeaderName: rawHeaderName.toUpperCase(), + headerValue, + }); + } + } + + // Sort in ascending order, with a being less than b if a's name is legacy-uppercased-byte less than b's name. + const sortedHeaders = [...unsortedHeaders.values()].sort((a, b) => { + if (a.upperHeaderName < b.upperHeaderName) { + return -1; + } + if (a.upperHeaderName > b.upperHeaderName) { + return 1; + } + return 0; + }); + + // Combine into single text response. + return ( + sortedHeaders + .map(header => { + return header.lowerHeaderName + ': ' + header.headerValue; + }) + .join('\r\n') + '\r\n' + ); + } + + getResponseHeader(header: string): ?string { + const value = this._lowerCaseResponseHeaders[header.toLowerCase()]; + return value !== undefined ? value : null; + } + + setRequestHeader(header: string, value: any): void { + if (this.readyState !== this.OPENED) { + throw new Error('Request has not been opened'); + } + this._headers[header.toLowerCase()] = String(value); + } + + /** + * Custom extension for tracking origins of request. + */ + setTrackingName(trackingName: string): XMLHttpRequest { + this._trackingName = trackingName; + return this; + } + + /** + * Custom extension for setting a custom performance logger + */ + setPerformanceLogger(performanceLogger: IPerformanceLogger): XMLHttpRequest { + this._performanceLogger = performanceLogger; + return this; + } + + open(method: string, url: string, async: ?boolean): void { + /* Other optional arguments are not supported yet */ + if (this.readyState !== this.UNSENT) { + throw new Error('Cannot open, already sending'); + } + if (async !== undefined && !async) { + // async is default + throw new Error('Synchronous http requests are not supported'); + } + if (!url) { + throw new Error('Cannot load an empty url'); + } + this._method = method.toUpperCase(); + this._url = url; + this._aborted = false; + this.setReadyState(this.OPENED); + } + + send(data: any): void { + if (this.readyState !== this.OPENED) { + throw new Error('Request has not been opened'); + } + if (this._sent) { + throw new Error('Request has already been sent'); + } + this._sent = true; + const incrementalEvents = + this._incrementalEvents || !!this.onreadystatechange || !!this.onprogress; + + this._subscriptions.push( + RCTNetworking.addListener('didSendNetworkData', args => + this.__didUploadProgress(...args), + ), + ); + this._subscriptions.push( + RCTNetworking.addListener('didReceiveNetworkResponse', args => + this.__didReceiveResponse(...args), + ), + ); + this._subscriptions.push( + RCTNetworking.addListener('didReceiveNetworkData', args => + this.__didReceiveData(...args), + ), + ); + this._subscriptions.push( + RCTNetworking.addListener('didReceiveNetworkIncrementalData', args => + this.__didReceiveIncrementalData(...args), + ), + ); + this._subscriptions.push( + RCTNetworking.addListener('didReceiveNetworkDataProgress', args => + this.__didReceiveDataProgress(...args), + ), + ); + this._subscriptions.push( + RCTNetworking.addListener('didCompleteNetworkResponse', args => + this.__didCompleteResponse(...args), + ), + ); + + let nativeResponseType: NativeResponseType = 'text'; + if (this._responseType === 'arraybuffer') { + nativeResponseType = 'base64'; + } + if (this._responseType === 'blob') { + nativeResponseType = 'blob'; + } + + const doSend = () => { + const friendlyName = + this._trackingName !== 'unknown' ? this._trackingName : this._url; + this._perfKey = 'network_XMLHttpRequest_' + String(friendlyName); + this._performanceLogger.startTimespan(this._perfKey); + this._startTime = performance.now(); + invariant( + this._method, + 'XMLHttpRequest method needs to be defined (%s).', + friendlyName, + ); + invariant( + this._url, + 'XMLHttpRequest URL needs to be defined (%s).', + friendlyName, + ); + RCTNetworking.sendRequest( + this._method, + this._trackingName, + this._url, + this._headers, + data, + /* $FlowFixMe(>=0.78.0 site=react_native_android_fb) This issue was found + * when making Flow check .android.js files. */ + nativeResponseType, + incrementalEvents, + this.timeout, + // $FlowFixMe[method-unbinding] added when improving typing for this parameters + this.__didCreateRequest.bind(this), + this.withCredentials, + ); + }; + if (DEBUG_NETWORK_SEND_DELAY) { + setTimeout(doSend, DEBUG_NETWORK_SEND_DELAY); + } else { + doSend(); + } + } + + abort(): void { + this._aborted = true; + if (this._requestId) { + RCTNetworking.abortRequest(this._requestId); + } + // only call onreadystatechange if there is something to abort, + // below logic is per spec + if ( + !( + this.readyState === this.UNSENT || + (this.readyState === this.OPENED && !this._sent) || + this.readyState === this.DONE + ) + ) { + this._reset(); + this.setReadyState(this.DONE); + } + // Reset again after, in case modified in handler + this._reset(); + } + + setResponseHeaders(responseHeaders: ?Object): void { + this.responseHeaders = responseHeaders || null; + const headers = responseHeaders || {}; + this._lowerCaseResponseHeaders = Object.keys(headers).reduce<{ + [string]: any, + }>((lcaseHeaders, headerName) => { + // $FlowFixMe[invalid-computed-prop] + lcaseHeaders[headerName.toLowerCase()] = headers[headerName]; + return lcaseHeaders; + }, {}); + } + + setReadyState(newState: number): void { + this.readyState = newState; + this.dispatchEvent({type: 'readystatechange'}); + if (newState === this.DONE) { + if (this._aborted) { + this.dispatchEvent({type: 'abort'}); + } else if (this._hasError) { + if (this._timedOut) { + this.dispatchEvent({type: 'timeout'}); + } else { + this.dispatchEvent({type: 'error'}); + } + } else { + this.dispatchEvent({type: 'load'}); + } + this.dispatchEvent({type: 'loadend'}); + } + } + + /* global EventListener */ + addEventListener(type: string, listener: EventListener): void { + // If we dont' have a 'readystatechange' event handler, we don't + // have to send repeated LOADING events with incremental updates + // to responseText, which will avoid a bunch of native -> JS + // bridge traffic. + if (type === 'readystatechange' || type === 'progress') { + this._incrementalEvents = true; + } + super.addEventListener(type, listener); + } + + _getMeasureURL(): string { + return ( + this._trackingName ?? this._url ?? LABEL_FOR_MISSING_URL_FOR_PROFILING + ); + } +} + +module.exports = XMLHttpRequest; diff --git a/packages/react-native/Libraries/Network/__tests__/XMLHttpRequest-test.js b/packages/react-native/Libraries/Network/__tests__/XMLHttpRequest-test.js index 1ed1fe4076e..bd2d4dd1f4d 100644 --- a/packages/react-native/Libraries/Network/__tests__/XMLHttpRequest-test.js +++ b/packages/react-native/Libraries/Network/__tests__/XMLHttpRequest-test.js @@ -49,267 +49,275 @@ jest }, }); -describe('XMLHttpRequest', function () { - let xhr; - let handleTimeout; - let handleError; - let handleLoad; - let handleReadyStateChange; - let handleLoadEnd; +[false, true].forEach(enableModern => { + describe(`XMLHttpRequest (${enableModern ? 'modern' : 'legacy'})`, function () { + beforeAll(() => { + jest.resetModules(); + global.RN$useBuiltInEventTarget = () => enableModern; + }); - beforeEach(() => { - xhr = new XMLHttpRequest(); + let xhr; + let handleTimeout; + let handleError; + let handleLoad; + let handleReadyStateChange; + let handleLoadEnd; - xhr.ontimeout = jest.fn(); - xhr.onerror = jest.fn(); - xhr.onload = jest.fn(); - xhr.onloadend = jest.fn(); - xhr.onreadystatechange = jest.fn(); + beforeEach(() => { + xhr = new XMLHttpRequest(); - handleTimeout = jest.fn(); - handleError = jest.fn(); - handleLoad = jest.fn(); - handleLoadEnd = jest.fn(); - handleReadyStateChange = jest.fn(); + xhr.ontimeout = jest.fn(); + xhr.onerror = jest.fn(); + xhr.onload = jest.fn(); + xhr.onloadend = jest.fn(); + xhr.onreadystatechange = jest.fn(); - xhr.addEventListener('timeout', handleTimeout); - xhr.addEventListener('error', handleError); - xhr.addEventListener('load', handleLoad); - xhr.addEventListener('loadend', handleLoadEnd); - xhr.addEventListener('readystatechange', handleReadyStateChange); + handleTimeout = jest.fn(); + handleError = jest.fn(); + handleLoad = jest.fn(); + handleLoadEnd = jest.fn(); + handleReadyStateChange = jest.fn(); - jest.clearAllMocks(); - }); + xhr.addEventListener('timeout', handleTimeout); + xhr.addEventListener('error', handleError); + xhr.addEventListener('load', handleLoad); + xhr.addEventListener('loadend', handleLoadEnd); + xhr.addEventListener('readystatechange', handleReadyStateChange); - afterEach(() => { - xhr = null; - handleTimeout = null; - handleError = null; - handleLoad = null; - handleLoadEnd = null; - handleReadyStateChange = null; - }); + jest.clearAllMocks(); + }); - it('should transition readyState correctly', function () { - expect(xhr.readyState).toBe(xhr.UNSENT); + afterEach(() => { + xhr = null; + handleTimeout = null; + handleError = null; + handleLoad = null; + handleLoadEnd = null; + handleReadyStateChange = null; + }); - xhr.open('GET', 'blabla'); + it('should transition readyState correctly', function () { + expect(xhr.readyState).toBe(xhr.UNSENT); - expect(xhr.onreadystatechange.mock.calls.length).toBe(1); - expect(handleReadyStateChange.mock.calls.length).toBe(1); - expect(xhr.readyState).toBe(xhr.OPENED); - }); + xhr.open('GET', 'blabla'); - it('should expose responseType correctly', function () { - expect(xhr.responseType).toBe(''); + expect(xhr.onreadystatechange.mock.calls.length).toBe(1); + expect(handleReadyStateChange.mock.calls.length).toBe(1); + expect(xhr.readyState).toBe(xhr.OPENED); + }); - jest.spyOn(console, 'warn').mockReturnValue(undefined); + it('should expose responseType correctly', function () { + expect(xhr.responseType).toBe(''); - // Setting responseType to an unsupported value has no effect. - xhr.responseType = 'arrayblobbuffertextfile'; - expect(xhr.responseType).toBe(''); + jest.spyOn(console, 'warn').mockReturnValue(undefined); - expect(console.warn).toBeCalledWith( - "The provided value 'arrayblobbuffertextfile' is not a valid 'responseType'.", - ); - console.warn.mockRestore(); + // Setting responseType to an unsupported value has no effect. + xhr.responseType = 'arrayblobbuffertextfile'; + expect(xhr.responseType).toBe(''); - xhr.responseType = 'arraybuffer'; - expect(xhr.responseType).toBe('arraybuffer'); + expect(console.warn).toBeCalledWith( + "The provided value 'arrayblobbuffertextfile' is not a valid 'responseType'.", + ); + console.warn.mockRestore(); + + xhr.responseType = 'arraybuffer'; + expect(xhr.responseType).toBe('arraybuffer'); + + // Can't change responseType after first data has been received. + xhr.open('GET', 'blabla'); + xhr.send(); + expect(() => { + xhr.responseType = 'text'; + }).toThrow(); + }); + + it('should expose responseText correctly', function () { + xhr.responseType = ''; + expect(xhr.responseText).toBe(''); + expect(xhr.response).toBe(''); + + xhr.responseType = 'arraybuffer'; + expect(() => xhr.responseText).toThrow(); + expect(xhr.response).toBe(null); - // Can't change responseType after first data has been received. - xhr.open('GET', 'blabla'); - xhr.send(); - expect(() => { xhr.responseType = 'text'; - }).toThrow(); - }); + expect(xhr.responseText).toBe(''); + expect(xhr.response).toBe(''); - it('should expose responseText correctly', function () { - xhr.responseType = ''; - expect(xhr.responseText).toBe(''); - expect(xhr.response).toBe(''); + // responseText is read-only. + expect(() => { + xhr.responseText = 'hi'; + }).toThrow(); + expect(xhr.responseText).toBe(''); + expect(xhr.response).toBe(''); - xhr.responseType = 'arraybuffer'; - expect(() => xhr.responseText).toThrow(); - expect(xhr.response).toBe(null); - - xhr.responseType = 'text'; - expect(xhr.responseText).toBe(''); - expect(xhr.response).toBe(''); - - // responseText is read-only. - expect(() => { - xhr.responseText = 'hi'; - }).toThrow(); - expect(xhr.responseText).toBe(''); - expect(xhr.response).toBe(''); - - xhr.open('GET', 'blabla'); - xhr.send(); - setRequestId(2); - xhr.__didReceiveData(requestId, 'Some data'); - expect(xhr.responseText).toBe('Some data'); - }); - - it('should call ontimeout function when the request times out', function () { - xhr.open('GET', 'blabla'); - xhr.send(); - setRequestId(3); - xhr.__didCompleteResponse(requestId, 'Timeout', true); - xhr.__didCompleteResponse(requestId, 'Timeout', true); - - expect(xhr.readyState).toBe(xhr.DONE); - - expect(xhr.ontimeout.mock.calls.length).toBe(1); - expect(xhr.onloadend.mock.calls.length).toBe(1); - expect(xhr.onerror).not.toBeCalled(); - expect(xhr.onload).not.toBeCalled(); - - expect(handleTimeout.mock.calls.length).toBe(1); - expect(handleLoadEnd.mock.calls.length).toBe(1); - expect(handleError).not.toBeCalled(); - expect(handleLoad).not.toBeCalled(); - }); - - it('should call onerror function when the request times out', function () { - xhr.open('GET', 'blabla'); - xhr.send(); - setRequestId(4); - xhr.__didCompleteResponse(requestId, 'Generic error'); - - expect(xhr.readyState).toBe(xhr.DONE); - - expect(xhr.onreadystatechange.mock.calls.length).toBe(2); - expect(xhr.onerror.mock.calls.length).toBe(1); - expect(xhr.onloadend.mock.calls.length).toBe(1); - expect(xhr.ontimeout).not.toBeCalled(); - expect(xhr.onload).not.toBeCalled(); - - expect(handleReadyStateChange.mock.calls.length).toBe(2); - expect(handleError.mock.calls.length).toBe(1); - expect(handleLoadEnd.mock.calls.length).toBe(1); - expect(handleTimeout).not.toBeCalled(); - expect(handleLoad).not.toBeCalled(); - }); - - it('should call onload function when there is no error', function () { - xhr.open('GET', 'blabla'); - xhr.send(); - setRequestId(5); - xhr.__didCompleteResponse(requestId, null); - - expect(xhr.readyState).toBe(xhr.DONE); - - expect(xhr.onreadystatechange.mock.calls.length).toBe(2); - expect(xhr.onload.mock.calls.length).toBe(1); - expect(xhr.onloadend.mock.calls.length).toBe(1); - expect(xhr.onerror).not.toBeCalled(); - expect(xhr.ontimeout).not.toBeCalled(); - - expect(handleReadyStateChange.mock.calls.length).toBe(2); - expect(handleLoad.mock.calls.length).toBe(1); - expect(handleLoadEnd.mock.calls.length).toBe(1); - expect(handleError).not.toBeCalled(); - expect(handleTimeout).not.toBeCalled(); - }); - - it('should call upload onprogress', function () { - xhr.open('GET', 'blabla'); - xhr.send(); - - xhr.upload.onprogress = jest.fn(); - const handleProgress = jest.fn(); - xhr.upload.addEventListener('progress', handleProgress); - setRequestId(6); - xhr.__didUploadProgress(requestId, 42, 100); - - expect(xhr.upload.onprogress.mock.calls.length).toBe(1); - expect(handleProgress.mock.calls.length).toBe(1); - - expect(xhr.upload.onprogress.mock.calls[0][0].loaded).toBe(42); - expect(xhr.upload.onprogress.mock.calls[0][0].total).toBe(100); - expect(handleProgress.mock.calls[0][0].loaded).toBe(42); - expect(handleProgress.mock.calls[0][0].total).toBe(100); - }); - - it('should combine response headers with CRLF', function () { - xhr.open('GET', 'blabla'); - xhr.send(); - setRequestId(7); - xhr.__didReceiveResponse(requestId, 200, { - 'Content-Type': 'text/plain; charset=utf-8', - 'Content-Length': '32', + xhr.open('GET', 'blabla'); + xhr.send(); + setRequestId(2); + xhr.__didReceiveData(requestId, 'Some data'); + expect(xhr.responseText).toBe('Some data'); }); - expect(xhr.getAllResponseHeaders()).toBe( - 'content-length: 32\r\n' + 'content-type: text/plain; charset=utf-8\r\n', - ); - }); + it('should call ontimeout function when the request times out', function () { + xhr.open('GET', 'blabla'); + xhr.send(); + setRequestId(3); + xhr.__didCompleteResponse(requestId, 'Timeout', true); + xhr.__didCompleteResponse(requestId, 'Timeout', true); - it('should log to GlobalPerformanceLogger if a custom performance logger is not set', () => { - xhr.open('GET', 'blabla'); - xhr.send(); + expect(xhr.readyState).toBe(xhr.DONE); - expect(GlobalPerformanceLogger.startTimespan).toHaveBeenCalledWith( - 'network_XMLHttpRequest_blabla', - ); - expect(GlobalPerformanceLogger.stopTimespan).not.toHaveBeenCalled(); + expect(xhr.ontimeout.mock.calls.length).toBe(1); + expect(xhr.onloadend.mock.calls.length).toBe(1); + expect(xhr.onerror).not.toBeCalled(); + expect(xhr.onload).not.toBeCalled(); - setRequestId(8); - xhr.__didReceiveResponse(requestId, 200, { - 'Content-Type': 'text/plain; charset=utf-8', - 'Content-Length': '32', + expect(handleTimeout.mock.calls.length).toBe(1); + expect(handleLoadEnd.mock.calls.length).toBe(1); + expect(handleError).not.toBeCalled(); + expect(handleLoad).not.toBeCalled(); }); - expect(GlobalPerformanceLogger.stopTimespan).toHaveBeenCalledWith( - 'network_XMLHttpRequest_blabla', - ); - }); + it('should call onerror function when the request times out', function () { + xhr.open('GET', 'blabla'); + xhr.send(); + setRequestId(4); + xhr.__didCompleteResponse(requestId, 'Generic error'); - it('should log to a custom performance logger if set', () => { - const performanceLogger = createPerformanceLogger(); - jest.spyOn(performanceLogger, 'startTimespan'); - jest.spyOn(performanceLogger, 'stopTimespan'); + expect(xhr.readyState).toBe(xhr.DONE); - xhr.setPerformanceLogger(performanceLogger); + expect(xhr.onreadystatechange.mock.calls.length).toBe(2); + expect(xhr.onerror.mock.calls.length).toBe(1); + expect(xhr.onloadend.mock.calls.length).toBe(1); + expect(xhr.ontimeout).not.toBeCalled(); + expect(xhr.onload).not.toBeCalled(); - xhr.open('GET', 'blabla'); - xhr.send(); - - expect(performanceLogger.startTimespan).toHaveBeenCalledWith( - 'network_XMLHttpRequest_blabla', - ); - expect(GlobalPerformanceLogger.startTimespan).not.toHaveBeenCalled(); - expect(performanceLogger.stopTimespan).not.toHaveBeenCalled(); - - setRequestId(9); - xhr.__didReceiveResponse(requestId, 200, { - 'Content-Type': 'text/plain; charset=utf-8', - 'Content-Length': '32', + expect(handleReadyStateChange.mock.calls.length).toBe(2); + expect(handleError.mock.calls.length).toBe(1); + expect(handleLoadEnd.mock.calls.length).toBe(1); + expect(handleTimeout).not.toBeCalled(); + expect(handleLoad).not.toBeCalled(); }); - expect(performanceLogger.stopTimespan).toHaveBeenCalledWith( - 'network_XMLHttpRequest_blabla', - ); - expect(GlobalPerformanceLogger.stopTimespan).not.toHaveBeenCalled(); - }); + it('should call onload function when there is no error', function () { + xhr.open('GET', 'blabla'); + xhr.send(); + setRequestId(5); + xhr.__didCompleteResponse(requestId, null); - it('should sort and lowercase response headers', function () { - // Derived from XHR Web Platform Test: https://github.com/web-platform-tests/wpt/blob/master/xhr/getallresponseheaders.htm - xhr.open('GET', 'blabla'); - xhr.send(); - setRequestId(10); - xhr.__didReceiveResponse(requestId, 200, { - 'foo-TEST': '1', - 'FOO-test': '2', - __Custom: 'token', - 'ALSO-here': 'Mr. PB', - ewok: 'lego', + expect(xhr.readyState).toBe(xhr.DONE); + + expect(xhr.onreadystatechange.mock.calls.length).toBe(2); + expect(xhr.onload.mock.calls.length).toBe(1); + expect(xhr.onloadend.mock.calls.length).toBe(1); + expect(xhr.onerror).not.toBeCalled(); + expect(xhr.ontimeout).not.toBeCalled(); + + expect(handleReadyStateChange.mock.calls.length).toBe(2); + expect(handleLoad.mock.calls.length).toBe(1); + expect(handleLoadEnd.mock.calls.length).toBe(1); + expect(handleError).not.toBeCalled(); + expect(handleTimeout).not.toBeCalled(); }); - expect(xhr.getAllResponseHeaders()).toBe( - 'also-here: Mr. PB\r\newok: lego\r\nfoo-test: 1, 2\r\n__custom: token\r\n', - ); + it('should call upload onprogress', function () { + xhr.open('GET', 'blabla'); + xhr.send(); + + xhr.upload.onprogress = jest.fn(); + const handleProgress = jest.fn(); + xhr.upload.addEventListener('progress', handleProgress); + setRequestId(6); + xhr.__didUploadProgress(requestId, 42, 100); + + expect(xhr.upload.onprogress.mock.calls.length).toBe(1); + expect(handleProgress.mock.calls.length).toBe(1); + + expect(xhr.upload.onprogress.mock.calls[0][0].loaded).toBe(42); + expect(xhr.upload.onprogress.mock.calls[0][0].total).toBe(100); + expect(handleProgress.mock.calls[0][0].loaded).toBe(42); + expect(handleProgress.mock.calls[0][0].total).toBe(100); + }); + + it('should combine response headers with CRLF', function () { + xhr.open('GET', 'blabla'); + xhr.send(); + setRequestId(7); + xhr.__didReceiveResponse(requestId, 200, { + 'Content-Type': 'text/plain; charset=utf-8', + 'Content-Length': '32', + }); + + expect(xhr.getAllResponseHeaders()).toBe( + 'content-length: 32\r\n' + + 'content-type: text/plain; charset=utf-8\r\n', + ); + }); + + it('should log to GlobalPerformanceLogger if a custom performance logger is not set', () => { + xhr.open('GET', 'blabla'); + xhr.send(); + + expect(GlobalPerformanceLogger.startTimespan).toHaveBeenCalledWith( + 'network_XMLHttpRequest_blabla', + ); + expect(GlobalPerformanceLogger.stopTimespan).not.toHaveBeenCalled(); + + setRequestId(8); + xhr.__didReceiveResponse(requestId, 200, { + 'Content-Type': 'text/plain; charset=utf-8', + 'Content-Length': '32', + }); + + expect(GlobalPerformanceLogger.stopTimespan).toHaveBeenCalledWith( + 'network_XMLHttpRequest_blabla', + ); + }); + + it('should log to a custom performance logger if set', () => { + const performanceLogger = createPerformanceLogger(); + jest.spyOn(performanceLogger, 'startTimespan'); + jest.spyOn(performanceLogger, 'stopTimespan'); + + xhr.setPerformanceLogger(performanceLogger); + + xhr.open('GET', 'blabla'); + xhr.send(); + + expect(performanceLogger.startTimespan).toHaveBeenCalledWith( + 'network_XMLHttpRequest_blabla', + ); + expect(GlobalPerformanceLogger.startTimespan).not.toHaveBeenCalled(); + expect(performanceLogger.stopTimespan).not.toHaveBeenCalled(); + + setRequestId(9); + xhr.__didReceiveResponse(requestId, 200, { + 'Content-Type': 'text/plain; charset=utf-8', + 'Content-Length': '32', + }); + + expect(performanceLogger.stopTimespan).toHaveBeenCalledWith( + 'network_XMLHttpRequest_blabla', + ); + expect(GlobalPerformanceLogger.stopTimespan).not.toHaveBeenCalled(); + }); + + it('should sort and lowercase response headers', function () { + // Derived from XHR Web Platform Test: https://github.com/web-platform-tests/wpt/blob/master/xhr/getallresponseheaders.htm + xhr.open('GET', 'blabla'); + xhr.send(); + setRequestId(10); + xhr.__didReceiveResponse(requestId, 200, { + 'foo-TEST': '1', + 'FOO-test': '2', + __Custom: 'token', + 'ALSO-here': 'Mr. PB', + ewok: 'lego', + }); + + expect(xhr.getAllResponseHeaders()).toBe( + 'also-here: Mr. PB\r\newok: lego\r\nfoo-test: 1, 2\r\n__custom: token\r\n', + ); + }); }); }); diff --git a/packages/react-native/Libraries/WebSocket/WebSocket.js b/packages/react-native/Libraries/WebSocket/WebSocket.js index 348161af248..ae15b2eb8b7 100644 --- a/packages/react-native/Libraries/WebSocket/WebSocket.js +++ b/packages/react-native/Libraries/WebSocket/WebSocket.js @@ -8,290 +8,13 @@ * @flow */ -import type {BlobData} from '../Blob/BlobTypes'; -import type {EventSubscription} from '../vendor/emitter/EventEmitter'; +import typeof WebSocket from './WebSocket_old'; -import Blob from '../Blob/Blob'; -import BlobManager from '../Blob/BlobManager'; -import NativeEventEmitter from '../EventEmitter/NativeEventEmitter'; -import binaryToBase64 from '../Utilities/binaryToBase64'; -import Platform from '../Utilities/Platform'; -import NativeWebSocketModule from './NativeWebSocketModule'; -import WebSocketEvent from './WebSocketEvent'; -import base64 from 'base64-js'; -import EventTarget from 'event-target-shim'; -import invariant from 'invariant'; +// Use a global instead of a flag from ReactNativeFeatureFlags because this will +// be read before apps have a chance to set overrides. +const useBuiltInEventTarget = global.RN$useBuiltInEventTarget?.(); -type ArrayBufferView = - | Int8Array - | Uint8Array - | Uint8ClampedArray - | Int16Array - | Uint16Array - | Int32Array - | Uint32Array - | Float32Array - | Float64Array - | DataView; - -type BinaryType = 'blob' | 'arraybuffer'; - -const CONNECTING = 0; -const OPEN = 1; -const CLOSING = 2; -const CLOSED = 3; - -const CLOSE_NORMAL = 1000; - -// Abnormal closure where no code is provided in a control frame -// https://www.rfc-editor.org/rfc/rfc6455.html#section-7.1.5 -const CLOSE_ABNORMAL = 1006; - -const WEBSOCKET_EVENTS = ['close', 'error', 'message', 'open']; - -let nextWebSocketId = 0; - -type WebSocketEventDefinitions = { - websocketOpen: [{id: number, protocol: string}], - websocketClosed: [{id: number, code: number, reason: string}], - websocketMessage: [ - | {type: 'binary', id: number, data: string} - | {type: 'text', id: number, data: string} - | {type: 'blob', id: number, data: BlobData}, - ], - websocketFailed: [{id: number, message: string}], -}; - -/** - * Browser-compatible WebSockets implementation. - * - * See https://developer.mozilla.org/en-US/docs/Web/API/WebSocket - * See https://github.com/websockets/ws - */ -class WebSocket extends (EventTarget(...WEBSOCKET_EVENTS): typeof EventTarget) { - static CONNECTING: number = CONNECTING; - static OPEN: number = OPEN; - static CLOSING: number = CLOSING; - static CLOSED: number = CLOSED; - - CONNECTING: number = CONNECTING; - OPEN: number = OPEN; - CLOSING: number = CLOSING; - CLOSED: number = CLOSED; - - _socketId: number; - _eventEmitter: NativeEventEmitter; - _subscriptions: Array; - _binaryType: ?BinaryType; - - onclose: ?Function; - onerror: ?Function; - onmessage: ?Function; - onopen: ?Function; - - bufferedAmount: number; - extension: ?string; - protocol: ?string; - readyState: number = CONNECTING; - url: ?string; - - constructor( - url: string, - protocols: ?string | ?Array, - options: ?{headers?: {origin?: string, ...}, ...}, - ) { - super(); - this.url = url; - if (typeof protocols === 'string') { - protocols = [protocols]; - } - - const {headers = {}, ...unrecognized} = options || {}; - - // Preserve deprecated backwards compatibility for the 'origin' option - // $FlowFixMe[prop-missing] - if (unrecognized && typeof unrecognized.origin === 'string') { - console.warn( - 'Specifying `origin` as a WebSocket connection option is deprecated. Include it under `headers` instead.', - ); - /* $FlowFixMe[prop-missing] (>=0.54.0 site=react_native_fb,react_native_ - * oss) This comment suppresses an error found when Flow v0.54 was - * deployed. To see the error delete this comment and run Flow. */ - headers.origin = unrecognized.origin; - /* $FlowFixMe[prop-missing] (>=0.54.0 site=react_native_fb,react_native_ - * oss) This comment suppresses an error found when Flow v0.54 was - * deployed. To see the error delete this comment and run Flow. */ - delete unrecognized.origin; - } - - // Warn about and discard anything else - if (Object.keys(unrecognized).length > 0) { - console.warn( - 'Unrecognized WebSocket connection option(s) `' + - Object.keys(unrecognized).join('`, `') + - '`. ' + - 'Did you mean to put these under `headers`?', - ); - } - - if (!Array.isArray(protocols)) { - protocols = null; - } - - this._eventEmitter = new NativeEventEmitter( - // T88715063: NativeEventEmitter only used this parameter on iOS. Now it uses it on all platforms, so this code was modified automatically to preserve its behavior - // If you want to use the native module on other platforms, please remove this condition and test its behavior - Platform.OS !== 'ios' ? null : NativeWebSocketModule, - ); - this._socketId = nextWebSocketId++; - this._registerEvents(); - NativeWebSocketModule.connect(url, protocols, {headers}, this._socketId); - } - - get binaryType(): ?BinaryType { - return this._binaryType; - } - - set binaryType(binaryType: BinaryType): void { - if (binaryType !== 'blob' && binaryType !== 'arraybuffer') { - throw new Error("binaryType must be either 'blob' or 'arraybuffer'"); - } - if (this._binaryType === 'blob' || binaryType === 'blob') { - invariant( - BlobManager.isAvailable, - 'Native module BlobModule is required for blob support', - ); - if (binaryType === 'blob') { - BlobManager.addWebSocketHandler(this._socketId); - } else { - BlobManager.removeWebSocketHandler(this._socketId); - } - } - this._binaryType = binaryType; - } - - close(code?: number, reason?: string): void { - if (this.readyState === this.CLOSING || this.readyState === this.CLOSED) { - return; - } - - this.readyState = this.CLOSING; - this._close(code, reason); - } - - send(data: string | ArrayBuffer | ArrayBufferView | Blob): void { - if (this.readyState === this.CONNECTING) { - throw new Error('INVALID_STATE_ERR'); - } - - if (data instanceof Blob) { - invariant( - BlobManager.isAvailable, - 'Native module BlobModule is required for blob support', - ); - BlobManager.sendOverSocket(data, this._socketId); - return; - } - - if (typeof data === 'string') { - NativeWebSocketModule.send(data, this._socketId); - return; - } - - if (data instanceof ArrayBuffer || ArrayBuffer.isView(data)) { - NativeWebSocketModule.sendBinary(binaryToBase64(data), this._socketId); - return; - } - - throw new Error('Unsupported data type'); - } - - ping(): void { - if (this.readyState === this.CONNECTING) { - throw new Error('INVALID_STATE_ERR'); - } - - NativeWebSocketModule.ping(this._socketId); - } - - _close(code?: number, reason?: string): void { - // See https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent - const statusCode = typeof code === 'number' ? code : CLOSE_NORMAL; - const closeReason = typeof reason === 'string' ? reason : ''; - NativeWebSocketModule.close(statusCode, closeReason, this._socketId); - - if (BlobManager.isAvailable && this._binaryType === 'blob') { - BlobManager.removeWebSocketHandler(this._socketId); - } - } - - _unregisterEvents(): void { - this._subscriptions.forEach(e => e.remove()); - this._subscriptions = []; - } - - _registerEvents(): void { - this._subscriptions = [ - this._eventEmitter.addListener('websocketMessage', ev => { - if (ev.id !== this._socketId) { - return; - } - let data: Blob | BlobData | ArrayBuffer | string = ev.data; - switch (ev.type) { - case 'binary': - data = base64.toByteArray(ev.data).buffer; - break; - case 'blob': - data = BlobManager.createFromOptions(ev.data); - break; - } - this.dispatchEvent(new WebSocketEvent('message', {data})); - }), - this._eventEmitter.addListener('websocketOpen', ev => { - if (ev.id !== this._socketId) { - return; - } - this.readyState = this.OPEN; - this.protocol = ev.protocol; - this.dispatchEvent(new WebSocketEvent('open')); - }), - this._eventEmitter.addListener('websocketClosed', ev => { - if (ev.id !== this._socketId) { - return; - } - this.readyState = this.CLOSED; - this.dispatchEvent( - new WebSocketEvent('close', { - code: ev.code, - reason: ev.reason, - // TODO: missing `wasClean` (exposed on iOS as `clean` but missing on Android) - }), - ); - this._unregisterEvents(); - this.close(); - }), - this._eventEmitter.addListener('websocketFailed', ev => { - if (ev.id !== this._socketId) { - return; - } - this.readyState = this.CLOSED; - this.dispatchEvent( - new WebSocketEvent('error', { - message: ev.message, - }), - ); - this.dispatchEvent( - new WebSocketEvent('close', { - code: CLOSE_ABNORMAL, - reason: ev.message, - // TODO: Expose `wasClean` - }), - ); - this._unregisterEvents(); - this.close(); - }), - ]; - } -} - -export default WebSocket; +export default (useBuiltInEventTarget + ? // $FlowExpectedError[incompatible-cast] + require('./WebSocket_new').default + : require('./WebSocket_old').default) as WebSocket; diff --git a/packages/react-native/Libraries/WebSocket/WebSocket_new.js b/packages/react-native/Libraries/WebSocket/WebSocket_new.js new file mode 100644 index 00000000000..3062613ab59 --- /dev/null +++ b/packages/react-native/Libraries/WebSocket/WebSocket_new.js @@ -0,0 +1,325 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @flow + */ + +import type {EventCallback} from '../../src/private/webapis/dom/events/EventTarget'; +import type {BlobData} from '../Blob/BlobTypes'; +import type {EventSubscription} from '../vendor/emitter/EventEmitter'; + +import Event from '../../src/private/webapis/dom/events/Event'; +import { + getEventHandlerAttribute, + setEventHandlerAttribute, +} from '../../src/private/webapis/dom/events/EventHandlerAttributes'; +import EventTarget from '../../src/private/webapis/dom/events/EventTarget'; +import MessageEvent from '../../src/private/webapis/html/events/MessageEvent'; +import CloseEvent from '../../src/private/webapis/websockets/events/CloseEvent'; +import Blob from '../Blob/Blob'; +import BlobManager from '../Blob/BlobManager'; +import NativeEventEmitter from '../EventEmitter/NativeEventEmitter'; +import binaryToBase64 from '../Utilities/binaryToBase64'; +import Platform from '../Utilities/Platform'; +import NativeWebSocketModule from './NativeWebSocketModule'; +import base64 from 'base64-js'; +import invariant from 'invariant'; + +type ArrayBufferView = + | Int8Array + | Uint8Array + | Uint8ClampedArray + | Int16Array + | Uint16Array + | Int32Array + | Uint32Array + | Float32Array + | Float64Array + | DataView; + +type BinaryType = 'blob' | 'arraybuffer'; + +const CONNECTING = 0; +const OPEN = 1; +const CLOSING = 2; +const CLOSED = 3; + +const CLOSE_NORMAL = 1000; + +// Abnormal closure where no code is provided in a control frame +// https://www.rfc-editor.org/rfc/rfc6455.html#section-7.1.5 +const CLOSE_ABNORMAL = 1006; + +let nextWebSocketId = 0; + +type WebSocketEventDefinitions = { + websocketOpen: [{id: number, protocol: string}], + websocketClosed: [{id: number, code: number, reason: string}], + websocketMessage: [ + | {type: 'binary', id: number, data: string} + | {type: 'text', id: number, data: string} + | {type: 'blob', id: number, data: BlobData}, + ], + websocketFailed: [{id: number, message: string}], +}; + +/** + * Browser-compatible WebSockets implementation. + * + * See https://developer.mozilla.org/en-US/docs/Web/API/WebSocket + * See https://github.com/websockets/ws + */ +class WebSocket extends EventTarget { + static CONNECTING: number = CONNECTING; + static OPEN: number = OPEN; + static CLOSING: number = CLOSING; + static CLOSED: number = CLOSED; + + CONNECTING: number = CONNECTING; + OPEN: number = OPEN; + CLOSING: number = CLOSING; + CLOSED: number = CLOSED; + + _socketId: number; + _eventEmitter: NativeEventEmitter; + _subscriptions: Array; + _binaryType: ?BinaryType; + + bufferedAmount: number; + extension: ?string; + protocol: ?string; + readyState: number = CONNECTING; + url: ?string; + + constructor( + url: string, + protocols: ?string | ?Array, + options: ?{headers?: {origin?: string, ...}, ...}, + ) { + super(); + this.url = url; + if (typeof protocols === 'string') { + protocols = [protocols]; + } + + const {headers = {}, ...unrecognized} = options || {}; + + // Preserve deprecated backwards compatibility for the 'origin' option + // $FlowFixMe[prop-missing] + if (unrecognized && typeof unrecognized.origin === 'string') { + console.warn( + 'Specifying `origin` as a WebSocket connection option is deprecated. Include it under `headers` instead.', + ); + /* $FlowFixMe[prop-missing] (>=0.54.0 site=react_native_fb,react_native_ + * oss) This comment suppresses an error found when Flow v0.54 was + * deployed. To see the error delete this comment and run Flow. */ + headers.origin = unrecognized.origin; + /* $FlowFixMe[prop-missing] (>=0.54.0 site=react_native_fb,react_native_ + * oss) This comment suppresses an error found when Flow v0.54 was + * deployed. To see the error delete this comment and run Flow. */ + delete unrecognized.origin; + } + + // Warn about and discard anything else + if (Object.keys(unrecognized).length > 0) { + console.warn( + 'Unrecognized WebSocket connection option(s) `' + + Object.keys(unrecognized).join('`, `') + + '`. ' + + 'Did you mean to put these under `headers`?', + ); + } + + if (!Array.isArray(protocols)) { + protocols = null; + } + + this._eventEmitter = new NativeEventEmitter( + // T88715063: NativeEventEmitter only used this parameter on iOS. Now it uses it on all platforms, so this code was modified automatically to preserve its behavior + // If you want to use the native module on other platforms, please remove this condition and test its behavior + Platform.OS !== 'ios' ? null : NativeWebSocketModule, + ); + this._socketId = nextWebSocketId++; + this._registerEvents(); + NativeWebSocketModule.connect(url, protocols, {headers}, this._socketId); + } + + get binaryType(): ?BinaryType { + return this._binaryType; + } + + set binaryType(binaryType: BinaryType): void { + if (binaryType !== 'blob' && binaryType !== 'arraybuffer') { + throw new Error("binaryType must be either 'blob' or 'arraybuffer'"); + } + if (this._binaryType === 'blob' || binaryType === 'blob') { + invariant( + BlobManager.isAvailable, + 'Native module BlobModule is required for blob support', + ); + if (binaryType === 'blob') { + BlobManager.addWebSocketHandler(this._socketId); + } else { + BlobManager.removeWebSocketHandler(this._socketId); + } + } + this._binaryType = binaryType; + } + + close(code?: number, reason?: string): void { + if (this.readyState === this.CLOSING || this.readyState === this.CLOSED) { + return; + } + + this.readyState = this.CLOSING; + this._close(code, reason); + } + + send(data: string | ArrayBuffer | ArrayBufferView | Blob): void { + if (this.readyState === this.CONNECTING) { + throw new Error('INVALID_STATE_ERR'); + } + + if (data instanceof Blob) { + invariant( + BlobManager.isAvailable, + 'Native module BlobModule is required for blob support', + ); + BlobManager.sendOverSocket(data, this._socketId); + return; + } + + if (typeof data === 'string') { + NativeWebSocketModule.send(data, this._socketId); + return; + } + + if (data instanceof ArrayBuffer || ArrayBuffer.isView(data)) { + NativeWebSocketModule.sendBinary(binaryToBase64(data), this._socketId); + return; + } + + throw new Error('Unsupported data type'); + } + + ping(): void { + if (this.readyState === this.CONNECTING) { + throw new Error('INVALID_STATE_ERR'); + } + + NativeWebSocketModule.ping(this._socketId); + } + + _close(code?: number, reason?: string): void { + // See https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent + const statusCode = typeof code === 'number' ? code : CLOSE_NORMAL; + const closeReason = typeof reason === 'string' ? reason : ''; + NativeWebSocketModule.close(statusCode, closeReason, this._socketId); + + if (BlobManager.isAvailable && this._binaryType === 'blob') { + BlobManager.removeWebSocketHandler(this._socketId); + } + } + + _unregisterEvents(): void { + this._subscriptions.forEach(e => e.remove()); + this._subscriptions = []; + } + + _registerEvents(): void { + this._subscriptions = [ + this._eventEmitter.addListener('websocketMessage', ev => { + if (ev.id !== this._socketId) { + return; + } + let data: Blob | BlobData | ArrayBuffer | string = ev.data; + switch (ev.type) { + case 'binary': + data = base64.toByteArray(ev.data).buffer; + break; + case 'blob': + data = BlobManager.createFromOptions(ev.data); + break; + } + this.dispatchEvent(new MessageEvent('message', {data})); + }), + this._eventEmitter.addListener('websocketOpen', ev => { + if (ev.id !== this._socketId) { + return; + } + this.readyState = this.OPEN; + this.protocol = ev.protocol; + this.dispatchEvent(new Event('open')); + }), + this._eventEmitter.addListener('websocketClosed', ev => { + if (ev.id !== this._socketId) { + return; + } + this.readyState = this.CLOSED; + this.dispatchEvent( + new CloseEvent('close', { + code: ev.code, + reason: ev.reason, + // TODO: missing `wasClean` (exposed on iOS as `clean` but missing on Android) + }), + ); + this._unregisterEvents(); + this.close(); + }), + this._eventEmitter.addListener('websocketFailed', ev => { + if (ev.id !== this._socketId) { + return; + } + this.readyState = this.CLOSED; + this.dispatchEvent(new Event('error')); + this.dispatchEvent( + new CloseEvent('close', { + code: CLOSE_ABNORMAL, + reason: ev.message, + // TODO: Expose `wasClean` + }), + ); + this._unregisterEvents(); + this.close(); + }), + ]; + } + + get onclose(): EventCallback | null { + return getEventHandlerAttribute(this, 'close'); + } + + set onclose(listener: ?EventCallback) { + setEventHandlerAttribute(this, 'close', listener); + } + + get onerror(): EventCallback | null { + return getEventHandlerAttribute(this, 'error'); + } + + set onerror(listener: ?EventCallback) { + setEventHandlerAttribute(this, 'error', listener); + } + + get onmessage(): EventCallback | null { + return getEventHandlerAttribute(this, 'message'); + } + + set onmessage(listener: ?EventCallback) { + setEventHandlerAttribute(this, 'message', listener); + } + + get onopen(): EventCallback | null { + return getEventHandlerAttribute(this, 'open'); + } + + set onopen(listener: ?EventCallback) { + setEventHandlerAttribute(this, 'open', listener); + } +} + +export default WebSocket; diff --git a/packages/react-native/Libraries/WebSocket/WebSocket_old.js b/packages/react-native/Libraries/WebSocket/WebSocket_old.js new file mode 100644 index 00000000000..348161af248 --- /dev/null +++ b/packages/react-native/Libraries/WebSocket/WebSocket_old.js @@ -0,0 +1,297 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @flow + */ + +import type {BlobData} from '../Blob/BlobTypes'; +import type {EventSubscription} from '../vendor/emitter/EventEmitter'; + +import Blob from '../Blob/Blob'; +import BlobManager from '../Blob/BlobManager'; +import NativeEventEmitter from '../EventEmitter/NativeEventEmitter'; +import binaryToBase64 from '../Utilities/binaryToBase64'; +import Platform from '../Utilities/Platform'; +import NativeWebSocketModule from './NativeWebSocketModule'; +import WebSocketEvent from './WebSocketEvent'; +import base64 from 'base64-js'; +import EventTarget from 'event-target-shim'; +import invariant from 'invariant'; + +type ArrayBufferView = + | Int8Array + | Uint8Array + | Uint8ClampedArray + | Int16Array + | Uint16Array + | Int32Array + | Uint32Array + | Float32Array + | Float64Array + | DataView; + +type BinaryType = 'blob' | 'arraybuffer'; + +const CONNECTING = 0; +const OPEN = 1; +const CLOSING = 2; +const CLOSED = 3; + +const CLOSE_NORMAL = 1000; + +// Abnormal closure where no code is provided in a control frame +// https://www.rfc-editor.org/rfc/rfc6455.html#section-7.1.5 +const CLOSE_ABNORMAL = 1006; + +const WEBSOCKET_EVENTS = ['close', 'error', 'message', 'open']; + +let nextWebSocketId = 0; + +type WebSocketEventDefinitions = { + websocketOpen: [{id: number, protocol: string}], + websocketClosed: [{id: number, code: number, reason: string}], + websocketMessage: [ + | {type: 'binary', id: number, data: string} + | {type: 'text', id: number, data: string} + | {type: 'blob', id: number, data: BlobData}, + ], + websocketFailed: [{id: number, message: string}], +}; + +/** + * Browser-compatible WebSockets implementation. + * + * See https://developer.mozilla.org/en-US/docs/Web/API/WebSocket + * See https://github.com/websockets/ws + */ +class WebSocket extends (EventTarget(...WEBSOCKET_EVENTS): typeof EventTarget) { + static CONNECTING: number = CONNECTING; + static OPEN: number = OPEN; + static CLOSING: number = CLOSING; + static CLOSED: number = CLOSED; + + CONNECTING: number = CONNECTING; + OPEN: number = OPEN; + CLOSING: number = CLOSING; + CLOSED: number = CLOSED; + + _socketId: number; + _eventEmitter: NativeEventEmitter; + _subscriptions: Array; + _binaryType: ?BinaryType; + + onclose: ?Function; + onerror: ?Function; + onmessage: ?Function; + onopen: ?Function; + + bufferedAmount: number; + extension: ?string; + protocol: ?string; + readyState: number = CONNECTING; + url: ?string; + + constructor( + url: string, + protocols: ?string | ?Array, + options: ?{headers?: {origin?: string, ...}, ...}, + ) { + super(); + this.url = url; + if (typeof protocols === 'string') { + protocols = [protocols]; + } + + const {headers = {}, ...unrecognized} = options || {}; + + // Preserve deprecated backwards compatibility for the 'origin' option + // $FlowFixMe[prop-missing] + if (unrecognized && typeof unrecognized.origin === 'string') { + console.warn( + 'Specifying `origin` as a WebSocket connection option is deprecated. Include it under `headers` instead.', + ); + /* $FlowFixMe[prop-missing] (>=0.54.0 site=react_native_fb,react_native_ + * oss) This comment suppresses an error found when Flow v0.54 was + * deployed. To see the error delete this comment and run Flow. */ + headers.origin = unrecognized.origin; + /* $FlowFixMe[prop-missing] (>=0.54.0 site=react_native_fb,react_native_ + * oss) This comment suppresses an error found when Flow v0.54 was + * deployed. To see the error delete this comment and run Flow. */ + delete unrecognized.origin; + } + + // Warn about and discard anything else + if (Object.keys(unrecognized).length > 0) { + console.warn( + 'Unrecognized WebSocket connection option(s) `' + + Object.keys(unrecognized).join('`, `') + + '`. ' + + 'Did you mean to put these under `headers`?', + ); + } + + if (!Array.isArray(protocols)) { + protocols = null; + } + + this._eventEmitter = new NativeEventEmitter( + // T88715063: NativeEventEmitter only used this parameter on iOS. Now it uses it on all platforms, so this code was modified automatically to preserve its behavior + // If you want to use the native module on other platforms, please remove this condition and test its behavior + Platform.OS !== 'ios' ? null : NativeWebSocketModule, + ); + this._socketId = nextWebSocketId++; + this._registerEvents(); + NativeWebSocketModule.connect(url, protocols, {headers}, this._socketId); + } + + get binaryType(): ?BinaryType { + return this._binaryType; + } + + set binaryType(binaryType: BinaryType): void { + if (binaryType !== 'blob' && binaryType !== 'arraybuffer') { + throw new Error("binaryType must be either 'blob' or 'arraybuffer'"); + } + if (this._binaryType === 'blob' || binaryType === 'blob') { + invariant( + BlobManager.isAvailable, + 'Native module BlobModule is required for blob support', + ); + if (binaryType === 'blob') { + BlobManager.addWebSocketHandler(this._socketId); + } else { + BlobManager.removeWebSocketHandler(this._socketId); + } + } + this._binaryType = binaryType; + } + + close(code?: number, reason?: string): void { + if (this.readyState === this.CLOSING || this.readyState === this.CLOSED) { + return; + } + + this.readyState = this.CLOSING; + this._close(code, reason); + } + + send(data: string | ArrayBuffer | ArrayBufferView | Blob): void { + if (this.readyState === this.CONNECTING) { + throw new Error('INVALID_STATE_ERR'); + } + + if (data instanceof Blob) { + invariant( + BlobManager.isAvailable, + 'Native module BlobModule is required for blob support', + ); + BlobManager.sendOverSocket(data, this._socketId); + return; + } + + if (typeof data === 'string') { + NativeWebSocketModule.send(data, this._socketId); + return; + } + + if (data instanceof ArrayBuffer || ArrayBuffer.isView(data)) { + NativeWebSocketModule.sendBinary(binaryToBase64(data), this._socketId); + return; + } + + throw new Error('Unsupported data type'); + } + + ping(): void { + if (this.readyState === this.CONNECTING) { + throw new Error('INVALID_STATE_ERR'); + } + + NativeWebSocketModule.ping(this._socketId); + } + + _close(code?: number, reason?: string): void { + // See https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent + const statusCode = typeof code === 'number' ? code : CLOSE_NORMAL; + const closeReason = typeof reason === 'string' ? reason : ''; + NativeWebSocketModule.close(statusCode, closeReason, this._socketId); + + if (BlobManager.isAvailable && this._binaryType === 'blob') { + BlobManager.removeWebSocketHandler(this._socketId); + } + } + + _unregisterEvents(): void { + this._subscriptions.forEach(e => e.remove()); + this._subscriptions = []; + } + + _registerEvents(): void { + this._subscriptions = [ + this._eventEmitter.addListener('websocketMessage', ev => { + if (ev.id !== this._socketId) { + return; + } + let data: Blob | BlobData | ArrayBuffer | string = ev.data; + switch (ev.type) { + case 'binary': + data = base64.toByteArray(ev.data).buffer; + break; + case 'blob': + data = BlobManager.createFromOptions(ev.data); + break; + } + this.dispatchEvent(new WebSocketEvent('message', {data})); + }), + this._eventEmitter.addListener('websocketOpen', ev => { + if (ev.id !== this._socketId) { + return; + } + this.readyState = this.OPEN; + this.protocol = ev.protocol; + this.dispatchEvent(new WebSocketEvent('open')); + }), + this._eventEmitter.addListener('websocketClosed', ev => { + if (ev.id !== this._socketId) { + return; + } + this.readyState = this.CLOSED; + this.dispatchEvent( + new WebSocketEvent('close', { + code: ev.code, + reason: ev.reason, + // TODO: missing `wasClean` (exposed on iOS as `clean` but missing on Android) + }), + ); + this._unregisterEvents(); + this.close(); + }), + this._eventEmitter.addListener('websocketFailed', ev => { + if (ev.id !== this._socketId) { + return; + } + this.readyState = this.CLOSED; + this.dispatchEvent( + new WebSocketEvent('error', { + message: ev.message, + }), + ); + this.dispatchEvent( + new WebSocketEvent('close', { + code: CLOSE_ABNORMAL, + reason: ev.message, + // TODO: Expose `wasClean` + }), + ); + this._unregisterEvents(); + this.close(); + }), + ]; + } +} + +export default WebSocket; diff --git a/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap b/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap index f9ec616b253..db272195f98 100644 --- a/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap +++ b/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap @@ -1453,6 +1453,52 @@ declare export default typeof File; `; exports[`public API should not change unintentionally Libraries/Blob/FileReader.js 1`] = ` +"declare export default FileReader; +" +`; + +exports[`public API should not change unintentionally Libraries/Blob/FileReader_new.js 1`] = ` +"type ReadyState = 0 | 1 | 2; +type ReaderResult = string | ArrayBuffer; +declare class FileReader extends EventTarget { + static EMPTY: number; + static LOADING: number; + static DONE: number; + EMPTY: number; + LOADING: number; + DONE: number; + _readyState: ReadyState; + _error: ?Error; + _result: ?ReaderResult; + _aborted: boolean; + constructor(): void; + _reset(): void; + _setReadyState(newState: ReadyState): void; + readAsArrayBuffer(blob: ?Blob): void; + readAsDataURL(blob: ?Blob): void; + readAsText(blob: ?Blob, encoding: string): void; + abort(): void; + get readyState(): ReadyState; + get error(): ?Error; + get result(): ?ReaderResult; + get onabort(): EventCallback | null; + set onabort(listener: ?EventCallback): void; + get onerror(): EventCallback | null; + set onerror(listener: ?EventCallback): void; + get onload(): EventCallback | null; + set onload(listener: ?EventCallback): void; + get onloadstart(): EventCallback | null; + set onloadstart(listener: ?EventCallback): void; + get onloadend(): EventCallback | null; + set onloadend(listener: ?EventCallback): void; + get onprogress(): EventCallback | null; + set onprogress(listener: ?EventCallback): void; +} +declare export default typeof FileReader; +" +`; + +exports[`public API should not change unintentionally Libraries/Blob/FileReader_old.js 1`] = ` "type ReadyState = 0 | 1 | 2; type ReaderResult = string | ArrayBuffer; declare class FileReader extends EventTarget { @@ -6529,6 +6575,155 @@ exports[`public API should not change unintentionally Libraries/Network/RCTNetwo `; exports[`public API should not change unintentionally Libraries/Network/XMLHttpRequest.js 1`] = ` +"export type * from \\"./XMLHttpRequest_old\\"; +declare module.exports: XMLHttpRequest; +" +`; + +exports[`public API should not change unintentionally Libraries/Network/XMLHttpRequest_new.js 1`] = ` +"export type NativeResponseType = \\"base64\\" | \\"blob\\" | \\"text\\"; +export type ResponseType = + | \\"\\" + | \\"arraybuffer\\" + | \\"blob\\" + | \\"document\\" + | \\"json\\" + | \\"text\\"; +export type Response = ?Object | string; +type XHRInterceptor = interface { + requestSent(id: number, url: string, method: string, headers: Object): void, + responseReceived( + id: number, + url: string, + status: number, + headers: Object + ): void, + dataReceived(id: number, data: string): void, + loadingFinished(id: number, encodedDataLength: number): void, + loadingFailed(id: number, error: string): void, +}; +declare class XMLHttpRequestEventTarget extends EventTarget { + get onload(): EventCallback | null; + set onload(listener: ?EventCallback): void; + get onloadstart(): EventCallback | null; + set onloadstart(listener: ?EventCallback): void; + get onprogress(): EventCallback | null; + set onprogress(listener: ?EventCallback): void; + get ontimeout(): EventCallback | null; + set ontimeout(listener: ?EventCallback): void; + get onerror(): EventCallback | null; + set onerror(listener: ?EventCallback): void; + get onabort(): EventCallback | null; + set onabort(listener: ?EventCallback): void; + get onloadend(): EventCallback | null; + set onloadend(listener: ?EventCallback): void; +} +declare class XMLHttpRequest extends EventTarget { + static UNSENT: number; + static OPENED: number; + static HEADERS_RECEIVED: number; + static LOADING: number; + static DONE: number; + static _interceptor: ?XHRInterceptor; + static _profiling: boolean; + UNSENT: number; + OPENED: number; + HEADERS_RECEIVED: number; + LOADING: number; + DONE: number; + readyState: number; + responseHeaders: ?Object; + status: number; + timeout: number; + responseURL: ?string; + withCredentials: boolean; + upload: XMLHttpRequestEventTarget; + _requestId: ?number; + _subscriptions: Array; + _aborted: boolean; + _cachedResponse: Response; + _hasError: boolean; + _headers: Object; + _lowerCaseResponseHeaders: Object; + _method: ?string; + _perfKey: ?string; + _responseType: ResponseType; + _response: string; + _sent: boolean; + _url: ?string; + _timedOut: boolean; + _trackingName: string; + _incrementalEvents: boolean; + _startTime: ?number; + _performanceLogger: IPerformanceLogger; + static setInterceptor(interceptor: ?XHRInterceptor): void; + static enableProfiling(enableProfiling: boolean): void; + constructor(): void; + _reset(): void; + get responseType(): ResponseType; + set responseType(responseType: ResponseType): void; + get responseText(): string; + get response(): Response; + __didCreateRequest(requestId: number): void; + __didUploadProgress(requestId: number, progress: number, total: number): void; + __didReceiveResponse( + requestId: number, + status: number, + responseHeaders: ?Object, + responseURL: ?string + ): void; + __didReceiveData(requestId: number, response: string): void; + __didReceiveIncrementalData( + requestId: number, + responseText: string, + progress: number, + total: number + ): void; + __didReceiveDataProgress( + requestId: number, + loaded: number, + total: number + ): void; + __didCompleteResponse( + requestId: number, + error: string, + timeOutError: boolean + ): void; + _clearSubscriptions(): void; + getAllResponseHeaders(): ?string; + getResponseHeader(header: string): ?string; + setRequestHeader(header: string, value: any): void; + setTrackingName(trackingName: string): XMLHttpRequest; + setPerformanceLogger(performanceLogger: IPerformanceLogger): XMLHttpRequest; + open(method: string, url: string, async: ?boolean): void; + send(data: any): void; + abort(): void; + setResponseHeaders(responseHeaders: ?Object): void; + setReadyState(newState: number): void; + addEventListener(type: string, listener: EventListener | null): void; + _getMeasureURL(): string; + get onabort(): EventCallback | null; + set onabort(listener: ?EventCallback): void; + get onerror(): EventCallback | null; + set onerror(listener: ?EventCallback): void; + get onload(): EventCallback | null; + set onload(listener: ?EventCallback): void; + get onloadstart(): EventCallback | null; + set onloadstart(listener: ?EventCallback): void; + get onprogress(): EventCallback | null; + set onprogress(listener: ?EventCallback): void; + get ontimeout(): EventCallback | null; + set ontimeout(listener: ?EventCallback): void; + get onloadend(): EventCallback | null; + set onloadend(listener: ?EventCallback): void; + get onreadystatechange(): EventCallback | null; + set onreadystatechange(listener: ?EventCallback): void; +} +declare module.exports: XMLHttpRequest; +" +`; + +exports[`public API should not change unintentionally Libraries/Network/XMLHttpRequest_old.js 1`] = ` "export type NativeResponseType = \\"base64\\" | \\"blob\\" | \\"text\\"; export type ResponseType = | \\"\\" @@ -9189,6 +9384,78 @@ declare export default typeof NativeWebSocketModule; `; exports[`public API should not change unintentionally Libraries/WebSocket/WebSocket.js 1`] = ` +"declare export default WebSocket; +" +`; + +exports[`public API should not change unintentionally Libraries/WebSocket/WebSocket_new.js 1`] = ` +"type ArrayBufferView = + | Int8Array + | Uint8Array + | Uint8ClampedArray + | Int16Array + | Uint16Array + | Int32Array + | Uint32Array + | Float32Array + | Float64Array + | DataView; +type BinaryType = \\"blob\\" | \\"arraybuffer\\"; +type WebSocketEventDefinitions = { + websocketOpen: [{ id: number, protocol: string }], + websocketClosed: [{ id: number, code: number, reason: string }], + websocketMessage: [ + | { type: \\"binary\\", id: number, data: string } + | { type: \\"text\\", id: number, data: string } + | { type: \\"blob\\", id: number, data: BlobData }, + ], + websocketFailed: [{ id: number, message: string }], +}; +declare class WebSocket extends EventTarget { + static CONNECTING: number; + static OPEN: number; + static CLOSING: number; + static CLOSED: number; + CONNECTING: number; + OPEN: number; + CLOSING: number; + CLOSED: number; + _socketId: number; + _eventEmitter: NativeEventEmitter; + _subscriptions: Array; + _binaryType: ?BinaryType; + bufferedAmount: number; + extension: ?string; + protocol: ?string; + readyState: number; + url: ?string; + constructor( + url: string, + protocols: ?string | ?Array, + options: ?{ headers?: { origin?: string, ... }, ... } + ): void; + get binaryType(): ?BinaryType; + set binaryType(binaryType: BinaryType): void; + close(code?: number, reason?: string): void; + send(data: string | ArrayBuffer | ArrayBufferView | Blob): void; + ping(): void; + _close(code?: number, reason?: string): void; + _unregisterEvents(): void; + _registerEvents(): void; + get onclose(): EventCallback | null; + set onclose(listener: ?EventCallback): void; + get onerror(): EventCallback | null; + set onerror(listener: ?EventCallback): void; + get onmessage(): EventCallback | null; + set onmessage(listener: ?EventCallback): void; + get onopen(): EventCallback | null; + set onopen(listener: ?EventCallback): void; +} +declare export default typeof WebSocket; +" +`; + +exports[`public API should not change unintentionally Libraries/WebSocket/WebSocket_old.js 1`] = ` "type ArrayBufferView = | Int8Array | Uint8Array diff --git a/packages/react-native/src/private/webapis/html/events/MessageEvent.js b/packages/react-native/src/private/webapis/html/events/MessageEvent.js new file mode 100644 index 00000000000..6400a04c43f --- /dev/null +++ b/packages/react-native/src/private/webapis/html/events/MessageEvent.js @@ -0,0 +1,63 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + * @format + */ + +/** + * This module implements the `MessageEvent` interface from the HTML spec. + * See https://html.spec.whatwg.org/multipage/comms.html#messageevent. + */ + +// flowlint unsafe-getters-setters:off + +import type {EventInit} from '../../dom/events/Event'; + +import Event from '../../dom/events/Event'; + +export type MessageEventInit = $ReadOnly<{ + ...EventInit, + data?: mixed, + origin?: string, + lastEventId?: string, + // Unsupported + // source?: MessageEventSource, + // Unsupported + // ports?: Array, +}>; + +export default class MessageEvent extends Event { + _data: mixed; + _origin: string; + _lastEventId: string; + + constructor(type: string, options?: ?MessageEventInit) { + const { + data, + origin = '', + lastEventId = '', + ...eventOptions + } = options ?? {}; + super(type, eventOptions); + + this._data = data; + this._origin = String(origin); + this._lastEventId = String(lastEventId); + } + + get data(): mixed { + return this._data; + } + + get origin(): string { + return this._origin; + } + + get lastEventId(): string { + return this._lastEventId; + } +} diff --git a/packages/react-native/src/private/webapis/websockets/events/CloseEvent.js b/packages/react-native/src/private/webapis/websockets/events/CloseEvent.js new file mode 100644 index 00000000000..691906be9f2 --- /dev/null +++ b/packages/react-native/src/private/webapis/websockets/events/CloseEvent.js @@ -0,0 +1,54 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + * @format + */ + +/** + * This module implements the `CloseEvent` interface from `WebSocket`. + * See https://websockets.spec.whatwg.org/#the-closeevent-interface. + */ + +// flowlint unsafe-getters-setters:off + +import type {EventInit} from '../../dom/events/Event'; + +import Event from '../../dom/events/Event'; + +export type CloseEventInit = $ReadOnly<{ + ...EventInit, + wasClean?: boolean, + code?: number, + reason?: string, +}>; + +export default class CloseEvent extends Event { + _wasClean: boolean; + _code: number; + _reason: string; + + constructor(type: string, options?: ?CloseEventInit) { + const {wasClean, code, reason, ...eventOptions} = options ?? {}; + super(type, eventOptions); + + this._wasClean = Boolean(wasClean); + this._code = Number(code) || 0; + this._reason = reason != null ? String(reason) : ''; + } + + get wasClean(): boolean { + return this._wasClean; + } + + get code(): number { + return this._code; + } + + get reason(): string { + return this._reason; + } +} diff --git a/packages/react-native/src/private/webapis/xhr/events/ProgressEvent.js b/packages/react-native/src/private/webapis/xhr/events/ProgressEvent.js new file mode 100644 index 00000000000..df67f28b8e4 --- /dev/null +++ b/packages/react-native/src/private/webapis/xhr/events/ProgressEvent.js @@ -0,0 +1,54 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + * @format + */ + +/** + * This module implements the `ProgressEvent` interface from `XMLHttpRequest`. + * See https://xhr.spec.whatwg.org/#interface-progressevent. + */ + +// flowlint unsafe-getters-setters:off + +import type {EventInit} from '../../dom/events/Event'; + +import Event from '../../dom/events/Event'; + +export type ProgressEventInit = $ReadOnly<{ + ...EventInit, + lengthComputable: boolean, + loaded: number, + total: number, +}>; + +export default class ProgressEvent extends Event { + _lengthComputable: boolean; + _loaded: number; + _total: number; + + constructor(type: string, options?: ?ProgressEventInit) { + const {lengthComputable, loaded, total, ...eventOptions} = options ?? {}; + super(type, eventOptions); + + this._lengthComputable = Boolean(lengthComputable); + this._loaded = Number(loaded) || 0; + this._total = Number(total) || 0; + } + + get lengthComputable(): boolean { + return this._lengthComputable; + } + + get loaded(): number { + return this._loaded; + } + + get total(): number { + return this._total; + } +}