diff --git a/.aegir.js b/.aegir.js index e0fddb4..ccabddb 100644 --- a/.aegir.js +++ b/.aegir.js @@ -1,12 +1,10 @@ -'use strict' +'use strict'; const EchoServer = require('aegir/utils/echo-server') const { format } =require('iso-url') -const path = require('path') /** @type {import('aegir').Options["build"]["config"]} */ const esbuild = { - //inject: [path.join(__dirname, '../../scripts/node-globals.js')], plugins: [ { name: 'node built ins', @@ -19,6 +17,7 @@ const esbuild = { ] } +/** @type {import('aegir').PartialOptions} */ module.exports = { build: { config: esbuild @@ -29,17 +28,21 @@ module.exports = { buildConfig: esbuild } }, - before: async () => { + async before (options) { let echoServer = new EchoServer() await echoServer.start() const { address, port } = echoServer.server.address() + let hostname = address + if(options.runner === 'react-native-android') { + hostname = '10.0.2.2' + } return { echoServer, - env: { ECHO_SERVER : format({ protocol: 'http:', hostname: address, port })} + env: { ECHO_SERVER : format({ protocol: 'http:', hostname, port })} } }, async after (options, before) { await before.echoServer.stop() } } -} \ No newline at end of file +} diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 9e81b21..b931a89 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,4 +1,7 @@ name: ci +env: + CI: true + FORCE_COLOR: 1 on: push: branches: @@ -11,16 +14,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 +67,34 @@ 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 + needs: check + steps: + - uses: actions/checkout@v2 + - run: npm install + - uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 29 + target: default + arch: x86_64 + profile: pixel + avd-name: aegir-android-29 + script: | + npx aegir test -t react-native-android + # 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 e35949e..e0d069e 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,10 @@ "electron-fetch": false, "fs": false }, + "react-native": { + "./src/fetch.js": "./src/fetch.rn.js", + "./src/http/fetch.js": "./src/http/fetch.rn.js" + }, "types": "dist/src/index.d.ts", "typesVersions": { "*": { @@ -36,6 +40,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", @@ -50,7 +56,7 @@ "electron-fetch": "^1.7.2", "err-code": "^3.0.1", "is-electron": "^2.2.0", - "iso-url": "^1.0.0", + "iso-url": "^1.1.5", "it-glob": "~0.0.11", "it-to-stream": "^1.0.0", "merge-options": "^3.0.4", @@ -58,16 +64,18 @@ "native-abort-controller": "^1.0.3", "native-fetch": "^3.0.0", "node-fetch": "^2.6.1", + "react-native-fetch-api": "^1.0.2", "stream-to-it": "^0.2.2" }, "devDependencies": { - "aegir": "^33.1.0", + "aegir": "^33.2.0", "delay": "^5.0.0", "events": "^3.3.0", "ipfs-unixfs": "^4.0.1", "it-all": "^1.0.4", "it-drain": "^1.0.3", "it-last": "^1.0.4", + "react-native-polyfill-globals": "^3.0.0", "readable-stream": "^3.6.0", "uint8arrays": "^2.0.5", "util": "^0.12.3" diff --git a/rn-test.config.js b/rn-test.config.js new file mode 100644 index 0000000..6e94e80 --- /dev/null +++ b/rn-test.config.js @@ -0,0 +1,14 @@ +'use strict' + +module.exports = { + require: require.resolve('./rn-test.require.js'), + runner: 'mocha', + modules: [ + 'react-native-url-polyfill', + 'web-streams-polyfill', + 'text-encoding' + ], + 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..9a37889 --- /dev/null +++ b/rn-test.require.js @@ -0,0 +1,9 @@ +'use strict' + +const { polyfill: polyfillReadableStream } = require('react-native-polyfill-globals/src/readable-stream') +const { polyfill: polyfillURL } = require('react-native-polyfill-globals/src/url') +const { polyfill: polyfillEncoding } = require('react-native-polyfill-globals/src/encoding') + +polyfillURL() +polyfillReadableStream() +polyfillEncoding() diff --git a/src/env.js b/src/env.js index eb1e826..c89a1b0 100644 --- a/src/env.js +++ b/src/env.js @@ -2,6 +2,7 @@ const isElectron = require('is-electron') const IS_ENV_WITH_DOM = typeof window === 'object' && typeof document === 'object' && document.nodeType === 9 +// @ts-ignore const IS_ELECTRON = isElectron() const IS_BROWSER = IS_ENV_WITH_DOM && !IS_ELECTRON const IS_ELECTRON_MAIN = IS_ELECTRON && !IS_ENV_WITH_DOM @@ -10,6 +11,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 +24,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.rn.js b/src/fetch.rn.js new file mode 100644 index 0000000..54688ed --- /dev/null +++ b/src/fetch.rn.js @@ -0,0 +1,18 @@ +// @ts-nocheck +'use strict' +// @ts-ignore +const { Headers, Request, Response, fetch } = require('react-native-fetch-api') + +/** @type {import('electron-fetch').default} */ +const rnFetch = fetch +/** @type {import('electron-fetch').Headers} */ +const rnHeaders = Headers +/** @type {import('electron-fetch').Request} */ +const rnRequest = Request +/** @type {import('electron-fetch').Response} */ +const rnResponse = Response +module.exports = rnFetch +module.exports.Headers = rnHeaders +module.exports.Request = rnRequest +module.exports.Response = rnResponse +module.exports.default = rnFetch diff --git a/src/http.js b/src/http.js index ba88fdf..9ce142c 100644 --- a/src/http.js +++ b/src/http.js @@ -9,10 +9,9 @@ const { AbortController } = require('native-abort-controller') const anySignal = require('any-signal') /** - * @typedef {import('./types').ExtendedResponse} ExtendedResponse * @typedef {import('stream').Readable} NodeReadableStream - * @typedef {import('stream').Duplex} NodeDuplexStream * @typedef {import('./types').HTTPOptions} HTTPOptions + * @typedef {import('./types').ExtendedResponse} ExtendedResponse */ /** diff --git a/src/http/fetch.js b/src/http/fetch.js index 752cc2a..3746641 100644 --- a/src/http/fetch.js +++ b/src/http/fetch.js @@ -1,8 +1,8 @@ 'use strict' -// Electron has `XMLHttpRequest` and should get the browser implementation -// instead of node. 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.node.js b/src/http/fetch.node.js index ae0ed0f..32b9d59 100644 --- a/src/http/fetch.node.js +++ b/src/http/fetch.node.js @@ -1,5 +1,4 @@ 'use strict' - const { Request, Response, Headers, default: nativeFetch } = require('../fetch') // @ts-ignore const toStream = require('it-to-stream') @@ -46,8 +45,7 @@ const withUploadProgress = (options) => { } /** - * @param {BodyInit} input - * @returns {Blob | FormData | URLSearchParams | ReadableStream | string | NodeReadableStream | Buffer} + * @param {BodyInit | NodeReadableStream} input */ const normalizeBody = (input) => { if (input instanceof ArrayBuffer) { diff --git a/src/http/fetch.rn.js b/src/http/fetch.rn.js new file mode 100644 index 0000000..da7735c --- /dev/null +++ b/src/http/fetch.rn.js @@ -0,0 +1,137 @@ +// @ts-nocheck +'use strict' + +const { TimeoutError, AbortError } = require('./error') +const { Response, Request, Headers, default: 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/types.d.ts b/src/types.d.ts index 2ee6223..b805e09 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -7,6 +7,10 @@ interface ProgressStatus { export interface ProgressFn { (status: ProgressStatus): void } export interface FetchOptions extends RequestInit { + /** + * Extended body with support for node readable stream + */ + body?: BodyInit | null | NodeReadableStream /** * Amount of time until request should timeout in ms. */ diff --git a/test/env.spec.js b/test/env.spec.js index 910783d..13d5352 100644 --- a/test/env.spec.js +++ b/test/env.spec.js @@ -22,6 +22,12 @@ describe('env', function () { case 'webworker': expect(env.isElectron).to.be.false() break + case 'react-native-android': + expect(env.isElectron).to.be.false() + break + case 'react-native-ios': + expect(env.isElectron).to.be.false() + break default: expect.fail(`Could not detect env. Current env is ${process.env.AEGIR_RUNNER}`) break @@ -45,6 +51,12 @@ describe('env', function () { case 'webworker': expect(env.isElectronMain).to.be.false() break + case 'react-native-android': + expect(env.isElectronMain).to.be.false() + break + case 'react-native-ios': + expect(env.isElectronMain).to.be.false() + break default: expect.fail(`Could not detect env. Current env is ${process.env.AEGIR_RUNNER}`) break @@ -68,6 +80,12 @@ describe('env', function () { case 'webworker': expect(env.isElectronRenderer).to.be.false() break + case 'react-native-android': + expect(env.isElectronRenderer).to.be.false() + break + case 'react-native-ios': + expect(env.isElectronRenderer).to.be.false() + break default: expect.fail(`Could not detect env. Current env is ${process.env.AEGIR_RUNNER}`) break @@ -91,6 +109,12 @@ describe('env', function () { case 'webworker': expect(env.isNode).to.be.false() break + case 'react-native-android': + expect(env.isNode).to.be.false() + break + case 'react-native-ios': + expect(env.isNode).to.be.false() + break default: expect.fail(`Could not detect env. Current env is ${process.env.AEGIR_RUNNER}`) break @@ -114,6 +138,12 @@ describe('env', function () { case 'webworker': expect(env.isBrowser).to.be.false() break + case 'react-native-android': + expect(env.isBrowser).to.be.false() + break + case 'react-native-ios': + expect(env.isBrowser).to.be.false() + break default: expect.fail(`Could not detect env. Current env is ${process.env.AEGIR_RUNNER}`) break @@ -137,6 +167,41 @@ describe('env', function () { case 'webworker': expect(env.isWebWorker).to.be.true() break + case 'react-native-android': + expect(env.isWebWorker).to.be.false() + break + case 'react-native-ios': + 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-android': + expect(env.isReactNative).to.be.true() + break + case 'react-native-ios': + 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..7e48c1f 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,11 +155,15 @@ describe('http', function () { await expect(drain(res.ndjson())).to.eventually.be.rejectedWith(/aborted/) }) - it('progress events', async () => { + it('progress events', async function () { + this.timeout(10000) let upload = 0 const body = new Uint8Array(1000000 / 2) const request = await HTTP.post(`${ECHO_SERVER}/echo`, { body, + headers: { + 'Content-Type': 'application/octet-stream' + }, onUploadProgress: (progress) => { expect(progress).to.have.property('lengthComputable').to.be.a('boolean') expect(progress).to.have.property('total', body.byteLength) 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() + } + }) }) diff --git a/tsconfig.json b/tsconfig.json index b008382..e882384 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,11 +1,10 @@ { "extends": "aegir/src/config/tsconfig.aegir.json", "compilerOptions": { - "outDir": "dist", - "baseUrl": "./", - "paths": { - "*": ["./types/*"] - } + "outDir": "dist" }, - "include": ["types", "test", "src"] + "include": [ + "test", + "src" + ] } diff --git a/types/is-electron/index.d.ts b/types/is-electron/index.d.ts deleted file mode 100644 index 0f33e26..0000000 --- a/types/is-electron/index.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -function isElectron (): boolean -export = isElectron