From 661b064b78f80182d64ef9b2319283a1465863e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Costa=20Lima?= Date: Sun, 31 Jan 2021 19:33:34 +0000 Subject: [PATCH] feat: add React Native support --- .aegir.js | 32 ++++---- .github/workflows/main.yml | 51 ++++++++++--- package.json | 12 ++- rn-test.config.js | 13 ++++ rn-test.require.js | 7 ++ src/env.js | 4 +- src/fetch.js | 6 +- src/http/fetch.js | 10 ++- src/http/fetch.react-native.js | 136 +++++++++++++++++++++++++++++++++ src/supports.js | 1 + src/text-encoder.js | 4 +- test/env.spec.js | 44 +++++++++++ test/http.spec.js | 14 ++-- test/supports.spec.js | 24 ++++++ 14 files changed, 314 insertions(+), 44 deletions(-) create mode 100644 rn-test.config.js create mode 100644 rn-test.require.js create mode 100644 src/http/fetch.react-native.js diff --git a/.aegir.js b/.aegir.js index 7733250..946194f 100644 --- a/.aegir.js +++ b/.aegir.js @@ -1,21 +1,23 @@ 'use strict' const EchoServer = require('aegir/utils/echo-server') -const { format } =require('iso-url') +const { format } = require('iso-url') let echo = new EchoServer() -module.exports = { - hooks: { - pre: async () => { - const server = await echo.start() - const { address, port } = server.server.address() - return { - env: { ECHO_SERVER : format({ protocol: 'http:', hostname: address, port })} - } - }, - post: async () => { - await echo.stop() - } - } -} \ No newline at end of file +// module.exports = { +// hooks: { +// pre: async () => { +// const server = await echo.start() +// const { address, port } = server.server.address() +// return { +// env: { ECHO_SERVER : format({ protocol: 'http:', hostname: address, port })} +// } +// }, +// post: async () => { +// await echo.stop() +// } +// } +// } + +echo.start() \ No newline at end of file diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 9e81b21..c67fb08 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -11,16 +11,16 @@ jobs: check: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - run: npm install - - run: npx aegir lint - - uses: gozala/typescript-error-reporter-action@v1.0.8 - - run: npx aegir build - - run: npx aegir dep-check - - uses: ipfs/aegir/actions/bundle-size@master - name: size - with: - github_token: ${{ secrets.GITHUB_TOKEN }} + - uses: actions/checkout@v2 + - run: npm install + - run: npx aegir lint + - uses: gozala/typescript-error-reporter-action@v1.0.8 + - run: npx aegir build + - run: npx aegir dep-check + - uses: ipfs/aegir/actions/bundle-size@master + name: size + with: + github_token: ${{ secrets.GITHUB_TOKEN }} test-node: needs: check runs-on: ${{ matrix.os }} @@ -64,4 +64,33 @@ jobs: steps: - uses: actions/checkout@v2 - run: npm install - - run: npx xvfb-maybe aegir test -t electron-renderer --bail \ No newline at end of file + - run: npx xvfb-maybe aegir test -t electron-renderer --bail + test-react-native-android: + runs-on: macos-latest + steps: + - uses: actions/checkout@v2 + - run: npm install + - uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 28 + target: default + arch: x86_64 + profile: pixel + avd-name: google-pixel + script: | + npx rn-test --platform android --emulator google-pixel 'test/**/*.spec.js' + test-react-native-ios: + runs-on: macos-latest + steps: + - uses: actions/checkout@v2 + - run: npm install + - name: Create and run iOS simulator + run: | + SIMULATOR_RUNTIME=$(echo "iOS 14.4" | sed 's/[ \.]/-/g') + SIMULATOR_ID=$(xcrun simctl create "iPhone 11" com.apple.CoreSimulator.SimDeviceType.iPhone-11 com.apple.CoreSimulator.SimRuntime.$SIMULATOR_RUNTIME) + echo "IOS_SIMULATOR=$SIMULATOR_ID" >> $GITHUB_ENV + xcrun simctl boot $SIMULATOR_ID & + - run: npx rn-test --platform ios --simulator 'iPhone 11 (14.4)' --rn 0.62.0 'test/**/*.spec.js' + - name: Shutdown iOS simulator + run: | + xcrun simctl shutdown $IOS_SIMULATOR diff --git a/package.json b/package.json index 7ac04e6..fa81d13 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,9 @@ "./test/files/glob-source.spec.js": false, "electron-fetch": false }, + "react-native": { + "./src/http/fetch.js": "./src/http/fetch.react-native.js" + }, "types": "dist/src/index.d.ts", "typesVersions": { "*": { @@ -34,6 +37,8 @@ "test:node": "aegir test -t node", "test:electron": "aegir test -t electron-main", "test:electron-renderer": "aegir test -t electron-renderer", + "test:react-native:android": "aegir test -t react-native-android", + "test:react-native:ios": "aegir test -t react-native-ios", "lint": "aegir lint", "release": "aegir release --docs", "release-minor": "aegir release --type minor --docs", @@ -49,7 +54,7 @@ "err-code": "^2.0.3", "fs-extra": "^9.0.1", "is-electron": "^2.2.0", - "iso-url": "^1.0.0", + "iso-url": "^1.1.3", "it-glob": "0.0.10", "it-to-stream": "^0.1.2", "merge-options": "^3.0.4", @@ -57,8 +62,9 @@ "native-abort-controller": "^1.0.3", "native-fetch": "2.0.1", "node-fetch": "^2.6.1", + "react-native-fetch-api": "^1.0.2", "stream-to-it": "^0.2.2", - "web-encoding": "^1.0.6" + "web-encoding": "^1.1.0" }, "devDependencies": { "@types/err-code": "^2.0.0", @@ -68,6 +74,8 @@ "it-all": "^1.0.4", "it-drain": "^1.0.3", "it-last": "^1.0.4", + "react-native-polyfill-globals": "^3.0.0", + "react-native-test-runner": "^3.0.2", "uint8arrays": "^2.0.5" }, "eslintConfig": { diff --git a/rn-test.config.js b/rn-test.config.js new file mode 100644 index 0000000..fa050c3 --- /dev/null +++ b/rn-test.config.js @@ -0,0 +1,13 @@ +'use strict' + +module.exports = { + require: require.resolve('./rn-test.require.js'), + runner: 'mocha', + modules: [ + 'react-native-url-polyfill', + 'web-streams-polyfill' + ], + patches: [{ + path: require.resolve('react-native-polyfill-globals/patches/react-native+0.63.3.patch') + }] +} diff --git a/rn-test.require.js b/rn-test.require.js new file mode 100644 index 0000000..6462828 --- /dev/null +++ b/rn-test.require.js @@ -0,0 +1,7 @@ +'use strict' + +const { polyfill: polyfillReadableStream } = require('react-native-polyfill-globals/src/readable-stream') +const { polyfill: polyfillURL } = require('react-native-polyfill-globals/src/url') + +polyfillURL() +polyfillReadableStream() diff --git a/src/env.js b/src/env.js index eb1e826..15fd2ef 100644 --- a/src/env.js +++ b/src/env.js @@ -10,6 +10,7 @@ const IS_NODE = typeof require === 'function' && typeof process !== 'undefined' // @ts-ignore - we either ignore worker scope or dom scope const IS_WEBWORKER = typeof importScripts === 'function' && typeof self !== 'undefined' && typeof WorkerGlobalScope !== 'undefined' && self instanceof WorkerGlobalScope const IS_TEST = typeof process !== 'undefined' && typeof process.env !== 'undefined' && process.env.NODE_ENV === 'test' +const IS_REACT_NATIVE = typeof navigator !== 'undefined' && navigator.product === 'ReactNative' module.exports = { isTest: IS_TEST, @@ -22,5 +23,6 @@ module.exports = { */ isBrowser: IS_BROWSER, isWebWorker: IS_WEBWORKER, - isEnvWithDom: IS_ENV_WITH_DOM + isEnvWithDom: IS_ENV_WITH_DOM, + isReactNative: IS_REACT_NATIVE } diff --git a/src/fetch.js b/src/fetch.js index d3ee792..ace949e 100644 --- a/src/fetch.js +++ b/src/fetch.js @@ -1,8 +1,10 @@ 'use strict' -const { isElectronMain } = require('./env') +const { isElectronMain, isReactNative } = require('./env') -if (isElectronMain) { +if (isReactNative) { + module.exports = require('react-native-fetch-api') +} else if (isElectronMain) { module.exports = require('electron-fetch') } else { // use window.fetch if it is available, fall back to node-fetch if not diff --git a/src/http/fetch.js b/src/http/fetch.js index 752cc2a..31d7ee4 100644 --- a/src/http/fetch.js +++ b/src/http/fetch.js @@ -1,8 +1,12 @@ 'use strict' -// Electron has `XMLHttpRequest` and should get the browser implementation -// instead of node. -if (typeof XMLHttpRequest === 'function') { +const { isReactNative } = require('../env') + +if (isReactNative) { + module.exports = require('./fetch.react-native') +} else if (typeof XMLHttpRequest === 'function') { + // Electron has `XMLHttpRequest` and should get the browser implementation + // instead of node. module.exports = require('./fetch.browser') } else { module.exports = require('./fetch.node') diff --git a/src/http/fetch.react-native.js b/src/http/fetch.react-native.js new file mode 100644 index 0000000..53ca0e1 --- /dev/null +++ b/src/http/fetch.react-native.js @@ -0,0 +1,136 @@ +'use strict' + +const { TimeoutError, AbortError } = require('./error') +const { Response, Request, Headers, fetch } = require('../fetch') + +/** + * @typedef {import('../types').FetchOptions} FetchOptions + * @typedef {import('../types').ProgressFn} ProgressFn + */ + +/** + * Fetch with progress + * + * @param {string | Request} url + * @param {FetchOptions} [options] + * @returns {Promise} + */ +const fetchWithProgress = (url, options = {}) => { + const request = new XMLHttpRequest() + request.open(options.method || 'GET', url.toString(), true) + + const { timeout, headers } = options + + if (timeout && timeout > 0 && timeout < Infinity) { + request.timeout = timeout + } + + if (options.overrideMimeType != null) { + request.overrideMimeType(options.overrideMimeType) + } + + if (headers) { + for (const [name, value] of new Headers(headers)) { + request.setRequestHeader(name, value) + } + } + + if (options.signal) { + options.signal.onabort = () => request.abort() + } + + if (options.onUploadProgress) { + request.upload.onprogress = options.onUploadProgress + } + + request.responseType = 'blob' + + return new Promise((resolve, reject) => { + /** + * @param {Event} event + */ + const handleEvent = (event) => { + switch (event.type) { + case 'error': { + resolve(Response.error()) + break + } + case 'load': { + resolve( + new ResponseWithURL(request.responseURL, request.response, { + status: request.status, + statusText: request.statusText, + headers: parseHeaders(request.getAllResponseHeaders()) + }) + ) + break + } + case 'timeout': { + reject(new TimeoutError()) + break + } + case 'abort': { + reject(new AbortError()) + break + } + default: { + break + } + } + } + request.onerror = handleEvent + request.onload = handleEvent + request.ontimeout = handleEvent + request.onabort = handleEvent + + request.send(/** @type {BodyInit} */(options.body)) + }) +} + +const fetchWithStreaming = fetch + +/** + * @param {string | Request} url + * @param {FetchOptions} options + */ +const fetchWith = (url, options = {}) => + (options.onUploadProgress != null) + ? fetchWithProgress(url, options) + : fetchWithStreaming(url, options) + +/** + * Parse Headers from a XMLHttpRequest + * + * @param {string} input + * @returns {Headers} + */ +const parseHeaders = (input) => { + const headers = new Headers() + for (const line of input.trim().split(/[\r\n]+/)) { + const index = line.indexOf(': ') + if (index > 0) { + headers.set(line.slice(0, index), line.slice(index + 1)) + } + } + + return headers +} + +class ResponseWithURL extends Response { + /** + * @param {string} url + * @param {BodyInit} body + * @param {ResponseInit} options + */ + constructor (url, body, options) { + super(body, options) + Object.defineProperty(this, 'url', { value: url }) + } +} + +module.exports = { + fetch: fetchWith, + Request, + Headers, + ResponseWithURL +} diff --git a/src/supports.js b/src/supports.js index f66f9bd..cadc86f 100644 --- a/src/supports.js +++ b/src/supports.js @@ -1,6 +1,7 @@ 'use strict' module.exports = { + // in React Native: global === window === self supportsFileReader: typeof self !== 'undefined' && 'FileReader' in self, supportsWebRTC: 'RTCPeerConnection' in globalThis && (typeof navigator !== 'undefined' && typeof navigator.mediaDevices !== 'undefined' && 'getUserMedia' in navigator.mediaDevices), diff --git a/src/text-encoder.js b/src/text-encoder.js index 6960639..b35e1fb 100644 --- a/src/text-encoder.js +++ b/src/text-encoder.js @@ -1,5 +1,3 @@ 'use strict' -const { TextEncoder } = require('web-encoding') - -module.exports = TextEncoder +module.exports = require('web-encoding').TextEncoder diff --git a/test/env.spec.js b/test/env.spec.js index 910783d..359bfee 100644 --- a/test/env.spec.js +++ b/test/env.spec.js @@ -22,6 +22,9 @@ describe('env', function () { case 'webworker': expect(env.isElectron).to.be.false() break + case 'react-native': + expect(env.isElectron).to.be.false() + break default: expect.fail(`Could not detect env. Current env is ${process.env.AEGIR_RUNNER}`) break @@ -45,6 +48,9 @@ describe('env', function () { case 'webworker': expect(env.isElectronMain).to.be.false() break + case 'react-native': + expect(env.isElectronMain).to.be.false() + break default: expect.fail(`Could not detect env. Current env is ${process.env.AEGIR_RUNNER}`) break @@ -68,6 +74,9 @@ describe('env', function () { case 'webworker': expect(env.isElectronRenderer).to.be.false() break + case 'react-native': + expect(env.isElectronRenderer).to.be.false() + break default: expect.fail(`Could not detect env. Current env is ${process.env.AEGIR_RUNNER}`) break @@ -91,6 +100,9 @@ describe('env', function () { case 'webworker': expect(env.isNode).to.be.false() break + case 'react-native': + expect(env.isNode).to.be.false() + break default: expect.fail(`Could not detect env. Current env is ${process.env.AEGIR_RUNNER}`) break @@ -114,6 +126,9 @@ describe('env', function () { case 'webworker': expect(env.isBrowser).to.be.false() break + case 'react-native': + expect(env.isBrowser).to.be.false() + break default: expect.fail(`Could not detect env. Current env is ${process.env.AEGIR_RUNNER}`) break @@ -137,6 +152,35 @@ describe('env', function () { case 'webworker': expect(env.isWebWorker).to.be.true() break + case 'react-native': + expect(env.isWebWorker).to.be.false() + break + default: + expect.fail(`Could not detect env. Current env is ${process.env.AEGIR_RUNNER}`) + break + } + }) + + it('isReactNative should have the correct value in each env', function () { + switch (process.env.AEGIR_RUNNER) { + case 'electron-main': + expect(env.isReactNative).to.be.false() + break + case 'electron-renderer': + expect(env.isReactNative).to.be.false() + break + case 'node': + expect(env.isReactNative).to.be.false() + break + case 'browser': + expect(env.isReactNative).to.be.false() + break + case 'webworker': + expect(env.isReactNative).to.be.false() + break + case 'react-native': + expect(env.isReactNative).to.be.true() + break default: expect.fail(`Could not detect env. Current env is ${process.env.AEGIR_RUNNER}`) break diff --git a/test/http.spec.js b/test/http.spec.js index 0d6f3bd..058a7a8 100644 --- a/test/http.spec.js +++ b/test/http.spec.js @@ -9,7 +9,7 @@ const delay = require('delay') const { AbortController } = require('native-abort-controller') const drain = require('it-drain') const all = require('it-all') -const { isBrowser, isWebWorker } = require('../src/env') +const { isBrowser, isWebWorker, isReactNative } = require('../src/env') const { Buffer } = require('buffer') const uint8ArrayFromString = require('uint8arrays/from-string') const uint8ArrayEquals = require('uint8arrays/equals') @@ -86,7 +86,7 @@ describe('http', function () { }) controller.abort() - await expect(res).to.eventually.be.rejectedWith(/aborted/) + await expect(res).to.eventually.be.rejectedWith(/aborted/i) }) it('parses the response as ndjson', async function () { @@ -111,8 +111,8 @@ describe('http', function () { }) it.skip('should handle errors in streaming bodies', async function () { - if (isBrowser || isWebWorker) { - // streaming bodies not supported by browsers + if (isBrowser || isWebWorker || isReactNative) { + // streaming bodies not supported by browsers nor by React Native return this.skip() } @@ -133,8 +133,8 @@ describe('http', function () { }) it.skip('should handle errors in streaming bodies when a signal is passed', async function () { - if (isBrowser || isWebWorker) { - // streaming bodies not supported by browsers + if (isBrowser || isWebWorker || isReactNative) { + // streaming bodies not supported by browsers nor by React Native return this.skip() } @@ -155,7 +155,7 @@ describe('http', function () { await expect(drain(res.ndjson())).to.eventually.be.rejectedWith(/aborted/) }) - it('progress events', async () => { + it('progress events', async function () { let upload = 0 const body = new Uint8Array(1000000 / 2) const request = await HTTP.post(`${ECHO_SERVER}/echo`, { diff --git a/test/supports.spec.js b/test/supports.spec.js index c4e11b4..b0070a8 100644 --- a/test/supports.spec.js +++ b/test/supports.spec.js @@ -46,6 +46,14 @@ describe('supports', function () { } }) + it('supportsFileReader should return true in React Native', function () { + if (env.isReactNative) { + expect(supports.supportsFileReader).to.be.true() + } else { + this.skip() + } + }) + it('supportsWebRTC should return false in node', function () { if (env.isNode) { expect(supports.supportsWebRTC).to.be.false() @@ -86,6 +94,14 @@ describe('supports', function () { } }) + it('supportsWebRTC should return false in React Native', function () { + if (env.isReactNative) { + expect(supports.supportsWebRTC).to.be.false() + } else { + this.skip() + } + }) + it('supportsWebRTCDataChannels should return false in node', function () { if (env.isNode) { expect(supports.supportsWebRTCDataChannels).to.be.false() @@ -125,4 +141,12 @@ describe('supports', function () { this.skip() } }) + + it('supportsWebRTCDataChannels should return true in React Native', function () { + if (env.isReactNative) { + expect(supports.supportsWebRTCDataChannels).to.be.false() + } else { + this.skip() + } + }) })