├── .cspell.json ├── .eslintrc.js ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── example ├── .babelrc ├── .env ├── .gitignore ├── App.tsx ├── README.md ├── __mocks__ │ └── Mock_1.jsx ├── babel.config.js ├── index.d.ts ├── package.json ├── scripts │ └── serve.js ├── tsconfig.json └── yarn.lock ├── package.json ├── src ├── @types │ └── index.ts ├── components │ ├── Wormhole.tsx │ └── index.ts ├── constants │ ├── createWormhole.tsx │ └── index.ts ├── hooks │ ├── index.ts │ └── useForceUpdate.ts └── index.ts ├── tsconfig.json └── yarn.lock /.cspell.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.1", 3 | "words": [ 4 | "cawfree", 5 | "deduped", 6 | "preload", 7 | "transpiled" 8 | ] 9 | } -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | node: true, 6 | 'jest/globals': true, 7 | }, 8 | extends: [ 9 | 'satya164', 10 | 'react-native', 11 | 'plugin:react/recommended', 12 | 'plugin:jest/recommended', 13 | ], 14 | parser: '@typescript-eslint/parser', 15 | parserOptions: { 16 | ecmaFeatures: { 17 | jsx: true, 18 | }, 19 | ecmaVersion: 12, 20 | sourceType: 'module', 21 | }, 22 | plugins: [ 23 | 'react', 24 | '@typescript-eslint', 25 | 'jest', 26 | ], 27 | rules: { 28 | }, 29 | }; 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | # .env 73 | # .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | # Distribution 107 | dist/ 108 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src/ 2 | example/ 3 | 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Alexander Thomas 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🌌 [`react-native-wormhole`](http://npmjs.com/package/react-native-wormhole) 2 | A `Wormhole` allows your [⚛️ **React Native**](https://reactnative.dev) application to consume components from a remote URL as if it were a local `import`, enabling them to easily become remotely configurable at runtime! 3 | 4 | [🎬 **Watch the Demo!**](https://twitter.com/cawfree/status/1370809787294879746) 5 | 6 | > ⚠️ Implementors must take care to protect their Wormholes from **arbitrary code execution**. Insufficient protection will put your user's data and device at risk. 💀 Please see [**Verification and Signing**](https://github.com/cawfree/react-native-wormhole#-verification-and-signing) for more information. 7 | 8 | ### 🚀 Getting Started 9 | 10 | Using [**Yarn**](https://yarnpkg.com): 11 | 12 | ```sh 13 | yarn add react-native-wormhole 14 | ``` 15 | 16 | Next, you'll need a component to serve. Let's create a quick project to demonstrate how this works: 17 | 18 | ``` 19 | mkdir my-new-wormhole 20 | cd my-new-wormhole 21 | yarn init 22 | yarn add --dev @babel/core @babel/cli @babel/preset-env @babel/preset-react 23 | ``` 24 | 25 | That should be enough. Inside `my-new-wormhole/`, let's quickly create a simple component: 26 | 27 | **`my-new-wormhole/MyNewWormhole.jsx`**: 28 | 29 | ```javascript 30 | import * as React from 'react'; 31 | import { Animated, Alert, TouchableOpacity } from 'react-native'; 32 | 33 | function CustomButton() { 34 | return ( 35 | Alert.alert('Hello!')}> 36 | 37 | 38 | ); 39 | } 40 | 41 | export default function MyNewWormhole() { 42 | const message = React.useMemo(() => 'Hello, world!', []); 43 | return ( 44 | 45 | {message} 46 | 47 | 48 | ); 49 | } 50 | ``` 51 | 52 | > 🤔 **What syntax am I allowed to use?** 53 | > 54 | > By default, you can use all functionality exported by `react` and `react-native`. The only requirement is that you must `export default` the Component that you wish to have served through the `Wormhole`. 55 | 56 | Now our component needs to be [**transpiled**](https://babeljs.io/docs/en/babel-cli). Below, we use [**Babel**](https://babeljs.io/) to convert `MyNewWormhole` into a format that can be executed at runtime: 57 | 58 | ``` 59 | npx babel --presets=@babel/preset-env,@babel/preset-react MyNewWormhole.jsx -o MyNewWormhole.js 60 | ``` 61 | 62 | After doing this, we'll have produced `MyNewWormhole.js`, which has been expressed in a format that is suitable to serve remotely. If you're unfamiliar with this process, take a quick look through the contents of the generated file to understand how it has changed. 63 | 64 | Next, you'd need to serve this file somewhere. For example, you could save it on GitHub, [**IPFS**](https://ipfs.io/) or on your own local server. To see an example of this, check out the [**Example Server**](./example/scripts/serve.js). 65 | 66 | > 👮 **Security Notice** 67 | > 68 | > In production environments, you **must** serve content using [**HTTPS**](https://en.wikipedia.org/wiki/HTTPS) to prevent [**Man in the Middle**](https://en.wikipedia.org/wiki/Man-in-the-middle_attack) attacks. Additionally, served content must be signed using public-key encryption to ensure authenticity of the returned source code. A demonstration of this approach using [**Ethers**](https://github.com/ethers-io/ethers.js/) is shown in the [**Example App**](https://github.com/cawfree/react-native-wormhole/blob/bdb127b21e403dab1fb63894f5d6764a92a002d4/example/App.tsx#L11). 69 | 70 | 71 | Finally, let's render our ``! For the purpose of this tutorial, let's assume the file is served at [https://cawfree.com/MyNewWormhole.jsx](https://cawfree.com/MyNewWormhole.jsx): 72 | 73 | ```javascript 74 | import * as React from 'react'; 75 | import { createWormhole } from 'react-native-wormhole'; 76 | 77 | const { Wormhole } = createWormhole({ 78 | verify: async () => true, 79 | }); 80 | 81 | export default function App() { 82 | return ; 83 | } 84 | ``` 85 | 86 | And that's everything! Once our component has finished downloading, it'll be mounted and visible on screen. 🚀 87 | 88 | ### 🔩 Configuration 89 | 90 | #### 🌎 Global Scope 91 | 92 | By default, a `Wormhole` is only capable of consuming global functionality from two different modules; [`react`](https://github.com/facebook/react) and [`react-native`](https://github.com/facebook/react-native), meaning that only "vanilla" React Native functionality is available. However, it is possible to introduce support for additional modules. In the snippet below, we show how to allow a `Wormhole` to render a [`WebView`](https://github.com/react-native-webview/react-native-webview): 93 | 94 | ```diff 95 | const { Wormhole } = createWormhole({ 96 | + global: { 97 | + require: (moduleId: string) => { 98 | + if (moduleId === 'react') { 99 | + return require('react'); 100 | + } else if (moduleId === 'react-native') { 101 | + return require('react-native'); 102 | + } else if (moduleId === 'react-native-webview') { 103 | + return require('react-native-webview); 104 | + } 105 | + return null; 106 | + }, 107 | + }, 108 | verify: async () => true, 109 | }); 110 | ``` 111 | 112 | > ⚠️ Version changes to `react`, `react-native` or any other dependencies your Wormholes consume may not be backwards-compatible. It's recommended that APIs serving content to requestors verify the compatibility of the requester version to avoid serving incompatible content. `react-native-wormhole` is **not** a package manager! 113 | 114 | #### 🔏 Verification and Signing 115 | 116 | Calls to [`createWormhole`](./src/constants/createWormhole.tsx) must at a minimum provide a `verify` function, which has the following declaration: 117 | 118 | ```typescript 119 | readonly verify: (response: AxiosResponse) => Promise; 120 | ``` 121 | 122 | This property is used to determine the integrity of a response, and is responsible for identifying whether remote content may be trusted for execution. If the `async` function does not return `true`, the request is terminated and the content will not be rendered via a `Wormhole`. In the [**Example App**](https://github.com/cawfree/react-native-wormhole/blob/bdb127b21e403dab1fb63894f5d6764a92a002d4/example/App.tsx#L11), we show how content can be signed to determine the authenticity of a response: 123 | 124 | ```diff 125 | + import { ethers } from 'ethers'; 126 | + import { SIGNER_ADDRESS, PORT } from '@env'; 127 | 128 | const { Wormhole } = createWormhole({ 129 | + verify: async ({ headers, data }: AxiosResponse) => { 130 | + const signature = headers['x-csrf-token']; 131 | + const bytes = ethers.utils.arrayify(signature); 132 | + const hash = ethers.utils.hashMessage(data); 133 | + const address = await ethers.utils.recoverAddress( 134 | + hash, 135 | + bytes 136 | + ); 137 | + return address === SIGNER_ADDRESS; 138 | + }, 139 | }); 140 | ``` 141 | 142 | In this implementation, the server is expected to return a HTTP response header `x-csrf-token` whose value is a [`signedMessage`](https://docs.ethers.io/v5/api/signer/) of the response body. Here, the client computes the expected signing address of the served content using the digest stored in the header. 143 | 144 | If the recovered address is not trusted, the script **will not be executed**. 145 | 146 | #### 🏎️ Preloading 147 | 148 | Making a call to `createWormhole()` also returns a `preload` function which can be used to asynchronously cache remote JSX before a `Wormhole` has been mounted: 149 | 150 | ```typescript 151 | const { preload } = createWormhole({ verify: async () => true }); 152 | 153 | (async () => { 154 | try { 155 | await preload('https://cawfree.com/MyNewWormhole.jsx'); 156 | } catch (e) { 157 | console.error('Failed to preload.'); 158 | } 159 | })(); 160 | ``` 161 | 162 | Wormholes dependent upon the external content will subsequently render immediately if the operation has completed in time. Meanwhile, concurrent requests to the same resource will be deduped. 163 | 164 | ### ✌️ License 165 | [**MIT**](./LICENSE) 166 | -------------------------------------------------------------------------------- /example/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env", "@babel/preset-react"] 3 | } 4 | -------------------------------------------------------------------------------- /example/.env: -------------------------------------------------------------------------------- 1 | PORT=3000 2 | SIGNER_ADDRESS=0xaecABE6Bd8213A8A311C6F7CCD54ee1A9D005776 3 | SIGNER_MNEMONIC=burst camera random collect manage steel grace fly potato giant legal mobile 4 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | # OSX 2 | # 3 | .DS_Store 4 | 5 | # Xcode 6 | # 7 | build/ 8 | *.pbxuser 9 | !default.pbxuser 10 | *.mode1v3 11 | !default.mode1v3 12 | *.mode2v3 13 | !default.mode2v3 14 | *.perspectivev3 15 | !default.perspectivev3 16 | xcuserdata 17 | *.xccheckout 18 | *.moved-aside 19 | DerivedData 20 | *.hmap 21 | *.ipa 22 | *.xcuserstate 23 | project.xcworkspace 24 | 25 | # Android/IntelliJ 26 | # 27 | build/ 28 | .idea 29 | .gradle 30 | local.properties 31 | *.iml 32 | 33 | # node.js 34 | # 35 | node_modules/ 36 | npm-debug.log 37 | yarn-error.log 38 | 39 | # BUCK 40 | buck-out/ 41 | \.buckd/ 42 | *.keystore 43 | 44 | # fastlane 45 | # 46 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 47 | # screenshots whenever they are needed. 48 | # For more information about the recommended setup visit: 49 | # https://docs.fastlane.tools/best-practices/source-control/ 50 | 51 | */fastlane/report.xml 52 | */fastlane/Preview.html 53 | */fastlane/screenshots 54 | 55 | # Bundle artifacts 56 | *.jsbundle 57 | 58 | # CocoaPods 59 | /ios/Pods/ 60 | 61 | # Expo 62 | .expo/* 63 | web-build/ 64 | 65 | # Mocks 66 | __mocks__/ 67 | -------------------------------------------------------------------------------- /example/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createWormhole } from 'react-native-wormhole'; 3 | import localhost from 'react-native-localhost'; 4 | import { ethers } from 'ethers'; 5 | 6 | import { SIGNER_ADDRESS, PORT } from '@env'; 7 | 8 | import type { AxiosResponse } from 'axios'; 9 | 10 | const { Wormhole } = createWormhole({ 11 | verify: async ({ headers, data }: AxiosResponse) => { 12 | const signature = headers['x-csrf-token']; 13 | const bytes = ethers.utils.arrayify(signature); 14 | const hash = ethers.utils.hashMessage(data); 15 | const address = await ethers.utils.recoverAddress( 16 | hash, 17 | bytes 18 | ); 19 | return address === SIGNER_ADDRESS; 20 | }, 21 | }); 22 | 23 | export default function App() { 24 | return ( 25 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | ```sh 2 | yarn; 3 | yarn serve; 4 | yarn ios; 5 | ``` 6 | -------------------------------------------------------------------------------- /example/__mocks__/Mock_1.jsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { 3 | Animated, 4 | Easing, 5 | ScrollView, 6 | StyleSheet, 7 | } from 'react-native'; 8 | 9 | const styles = StyleSheet.create({ 10 | container: { 11 | backgroundColor: '#222222', 12 | padding: 15, 13 | width: '100%', 14 | }, 15 | header: { 16 | aspectRatio: 1, 17 | width: '100%', 18 | }, 19 | }); 20 | 21 | function usePeriodic() { 22 | const progress = React.useMemo(() => new Animated.Value(0), []); 23 | const animate = React.useCallback(function shouldAnimate(animated) { 24 | animated.setValue(0); 25 | return Animated.timing( 26 | animated, 27 | { toValue: 1, duration: 5000, useNativeDriver: true, easing: Easing.linear }, 28 | ).start(() => shouldAnimate(animated)); 29 | }, []); 30 | React.useEffect(() => { 31 | animate(progress); 32 | }, [animate, progress]); 33 | return progress; 34 | } 35 | 36 | export default function Mock_1() { 37 | const spinner = usePeriodic(); 38 | return ( 39 | 40 | 57 | 58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /example/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function(api) { 2 | api.cache(true); 3 | return { 4 | presets: ['babel-preset-expo'], 5 | plugins: [ 6 | ['module:react-native-dotenv'] 7 | ], 8 | }; 9 | }; 10 | -------------------------------------------------------------------------------- /example/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@env' { 2 | export const PORT: number; 3 | export const SIGNER_ADDRESS: string; 4 | } 5 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "ethers": "^5.0.32", 4 | "expo": "~40.0.0", 5 | "react": "16.13.1", 6 | "react-dom": "16.13.1", 7 | "react-native": "https://github.com/expo/react-native/archive/sdk-40.0.1.tar.gz", 8 | "react-native-dotenv": "^2.5.3", 9 | "react-native-localhost": "^1.0.0", 10 | "react-native-screens": "~2.15.0", 11 | "react-native-web": "~0.13.12", 12 | "react-native-wormhole": "0.2.0" 13 | }, 14 | "devDependencies": { 15 | "@babel/cli": "^7.13.10", 16 | "@babel/core": "^7.13.10", 17 | "@babel/preset-env": "^7.13.10", 18 | "@babel/preset-react": "^7.12.13", 19 | "@types/react": "~16.9.35", 20 | "@types/react-native": "~0.63.2", 21 | "chalk": "^4.1.0", 22 | "dotenv": "^8.2.0", 23 | "express": "^4.17.1", 24 | "typescript": "~4.0.0" 25 | }, 26 | "scripts": { 27 | "start": "expo start", 28 | "android": "expo start --android", 29 | "ios": "expo start --ios", 30 | "web": "expo start --web", 31 | "eject": "expo eject", 32 | "serve": "node scripts/serve" 33 | }, 34 | "private": true 35 | } 36 | -------------------------------------------------------------------------------- /example/scripts/serve.js: -------------------------------------------------------------------------------- 1 | require('dotenv/config'); 2 | 3 | const express = require('express'); 4 | const path = require('path'); 5 | const fs = require('fs'); 6 | const child_process = require('child_process'); 7 | const chalk = require('chalk'); 8 | const appRootPath = require('app-root-path'); 9 | 10 | const { ethers } = require('ethers'); 11 | const mocks = path.resolve(`${appRootPath}`, '__mocks__'); 12 | 13 | const serveTranspiledFile = wallet => async (req, res, next) => { 14 | try { 15 | const { params } = req; 16 | const { wormhole } = params; 17 | const file = path.resolve(mocks, wormhole); 18 | if (!fs.existsSync(file)) { 19 | throw new Error(`Unable to find ${file}`); 20 | } 21 | const src = child_process.execSync( 22 | `npx babel --presets=@babel/preset-env,@babel/preset-react ${wormhole}`, 23 | { cwd: `${mocks}` }, 24 | ).toString(); 25 | const signature = await wallet.signMessage(src); 26 | return res 27 | .status(200) 28 | .set({ 'X-Csrf-Token': signature }) 29 | .send(src); 30 | } catch (e) { 31 | return next(e); 32 | } 33 | }; 34 | 35 | (async () => { 36 | const { PORT, SIGNER_MNEMONIC } = process.env; 37 | const wallet = await ethers.Wallet.fromMnemonic(SIGNER_MNEMONIC); 38 | await new Promise( 39 | resolve => express() 40 | .get('/__mocks__/:wormhole', serveTranspiledFile(wallet)) 41 | .listen(PORT, resolve), 42 | ); 43 | console.clear(); 44 | console.log(chalk.white.bold`🕳️ 🐛 Wormholes are being served!`); 45 | console.log('Note, request latency will be increased since files will be lazily recompiled on every request.'); 46 | console.log(chalk.green.bold`Port: ${PORT}`); 47 | })(); 48 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react-native", 4 | "target": "esnext", 5 | "lib": ["dom", "esnext"], 6 | "allowJs": true, 7 | "skipLibCheck": true, 8 | "noEmit": true, 9 | "allowSyntheticDefaultImports": true, 10 | "resolveJsonModule": true, 11 | "esModuleInterop": true, 12 | "moduleResolution": "node" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-native-wormhole", 3 | "version": "0.2.0", 4 | "description": "⚛️ 🌌 Inter-dimensional Portals for React Native. 👽 🖖", 5 | "main": "dist", 6 | "scripts": { 7 | "build": "rm -rf dist ; yarn && yarn tsc && rm -rf node_modules" 8 | }, 9 | "keywords": [ 10 | "react-native", 11 | "plugin", 12 | "component", 13 | "dynamic", 14 | "loader", 15 | "render" 16 | ], 17 | "repository": "https://github.com/cawfree/react-native-wormhole", 18 | "author": "Alex Thomas (@cawfree) ", 19 | "license": "MIT", 20 | "private": false, 21 | "devDependencies": { 22 | "@babel/cli": "^7.13.10", 23 | "@babel/core": "^7.13.10", 24 | "@babel/polyfill": "^7.12.1", 25 | "@babel/preset-env": "^7.13.10", 26 | "@babel/preset-react": "^7.12.13", 27 | "@babel/preset-typescript": "^7.13.0", 28 | "@types/app-root-path": "^1.2.4", 29 | "@types/axios": "^0.14.0", 30 | "@types/react": "^17.0.3", 31 | "@types/react-native": "^0.63.51", 32 | "@typescript-eslint/eslint-plugin": "^4.17.0", 33 | "@typescript-eslint/parser": "^4.17.0", 34 | "app-root-path": "^3.0.0", 35 | "eslint": "^7.22.0", 36 | "eslint-config-satya164": "^3.1.9", 37 | "eslint-plugin-import": "^2.22.1", 38 | "eslint-plugin-jest": "^24.3.1", 39 | "eslint-plugin-jsx-a11y": "^6.4.1", 40 | "eslint-plugin-react": "^7.22.0", 41 | "eslint-plugin-react-hooks": "^4.2.0", 42 | "eslint-plugin-react-native": "^3.10.0", 43 | "react": "^17.0.1", 44 | "react-native": "^0.64.0", 45 | "typescript": "^4.2.3" 46 | }, 47 | "dependencies": { 48 | "@babel/runtime": "^7.13.10", 49 | "axios": "^0.21.1", 50 | "react-error-boundary": "^3.1.1" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/@types/index.ts: -------------------------------------------------------------------------------- 1 | import { AxiosPromise, AxiosRequestConfig, AxiosResponse } from 'axios'; 2 | 3 | export type PromiseCallback = { 4 | readonly resolve: (result: T) => void; 5 | readonly reject: (error: Error) => void; 6 | }; 7 | 8 | export type WormholeSource = { 9 | readonly uri: string; 10 | } | string; 11 | 12 | export type WormholeComponentCache = { 13 | readonly [uri: string]: React.Component | null; 14 | }; 15 | 16 | export type WormholeTasks = { 17 | readonly [uri: string]: PromiseCallback[]; 18 | }; 19 | 20 | export type WormholeOptions = { 21 | readonly dangerouslySetInnerJSX: boolean; 22 | }; 23 | 24 | export type WormholeContextConfig = { 25 | readonly verify: (response: AxiosResponse) => Promise; 26 | readonly buildRequestForUri?: (config: AxiosRequestConfig) => AxiosPromise; 27 | readonly global?: any; 28 | }; 29 | -------------------------------------------------------------------------------- /src/components/Wormhole.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { ErrorBoundary } from 'react-error-boundary'; 3 | 4 | import { WormholeOptions, WormholeSource } from '../@types'; 5 | import { useForceUpdate } from '../hooks'; 6 | 7 | export type WormholeProps = { 8 | readonly source: WormholeSource; 9 | readonly renderLoading?: () => JSX.Element; 10 | readonly renderError?: (props: { readonly error: Error }) => JSX.Element; 11 | readonly dangerouslySetInnerJSX?: boolean; 12 | readonly onError?: (error: Error) => void; 13 | readonly shouldOpenWormhole?: ( 14 | source: WormholeSource, 15 | options: WormholeOptions 16 | ) => Promise; 17 | }; 18 | 19 | export default function Wormhole({ 20 | source, 21 | renderLoading = () => , 22 | renderError = () => , 23 | dangerouslySetInnerJSX = false, 24 | onError = console.error, 25 | shouldOpenWormhole, 26 | ...extras 27 | }: WormholeProps): JSX.Element { 28 | const { forceUpdate } = useForceUpdate(); 29 | const [Component, setComponent] = React.useState(null); 30 | const [error, setError] = React.useState(null); 31 | React.useEffect(() => { 32 | (async () => { 33 | try { 34 | if (typeof shouldOpenWormhole === 'function') { 35 | const Component = await shouldOpenWormhole(source, { dangerouslySetInnerJSX }); 36 | return setComponent(() => Component); 37 | } 38 | throw new Error( 39 | `[Wormhole]: Expected function shouldOpenWormhole, encountered ${ 40 | typeof shouldOpenWormhole 41 | }.` 42 | ); 43 | } catch (e) { 44 | setComponent(() => null); 45 | setError(e); 46 | onError(e); 47 | return forceUpdate(); 48 | } 49 | })(); 50 | }, [ 51 | shouldOpenWormhole, 52 | source, 53 | setComponent, 54 | forceUpdate, 55 | setError, 56 | dangerouslySetInnerJSX, 57 | onError, 58 | ]); 59 | const FallbackComponent = React.useCallback((): JSX.Element => { 60 | return renderError({ error: new Error('[Wormhole]: Failed to render.') }); 61 | }, [renderError]); 62 | if (typeof Component === 'function') { 63 | return ( 64 | 65 | {/* @ts-ignore */} 66 | 67 | 68 | ); 69 | } else if (error) { 70 | return renderError({ error }); 71 | } 72 | return renderLoading(); 73 | } 74 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Wormhole } from './Wormhole'; 2 | -------------------------------------------------------------------------------- /src/constants/createWormhole.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import axios, { AxiosPromise, AxiosRequestConfig, AxiosResponse } from 'axios'; 3 | 4 | import { 5 | PromiseCallback, 6 | WormholeContextConfig, 7 | WormholeSource, 8 | WormholeOptions, 9 | WormholeComponentCache, 10 | WormholeTasks, 11 | } from '../@types'; 12 | 13 | import { Wormhole as BaseWormhole } from '../components'; 14 | import { WormholeProps } from '../components/Wormhole'; 15 | 16 | const globalName = '__WORMHOLE__'; 17 | 18 | const defaultGlobal = Object.freeze({ 19 | require: (moduleId: string) => { 20 | if (moduleId === 'react') { 21 | // @ts-ignore 22 | return require('react'); 23 | } else if (moduleId === 'react-native') { 24 | // @ts-ignore 25 | return require('react-native'); 26 | } 27 | return null; 28 | }, 29 | }); 30 | 31 | const buildCompletionHandler = ( 32 | cache: WormholeComponentCache, 33 | tasks: WormholeTasks, 34 | ) => (uri: string, error?: Error): void => { 35 | const { [uri]: maybeComponent } = cache; 36 | const { [uri]: callbacks } = tasks; 37 | Object.assign(tasks, { [uri]: null }); 38 | callbacks.forEach(({ resolve, reject }) => { 39 | if (!!maybeComponent) { 40 | return resolve(maybeComponent); 41 | } 42 | return reject( 43 | error || new Error(`[Wormhole]: Failed to allocate for uri "${uri}".`) 44 | ); 45 | }); 46 | }; 47 | 48 | const buildCreateComponent = ( 49 | global: any 50 | ) => async (src: string): Promise => { 51 | const Component = await new Function( 52 | globalName, 53 | `${Object.keys(global).map((key) => `var ${key} = ${globalName}.${key};`).join('\n')}; const exports = {}; ${src}; return exports.default;` 54 | )(global); 55 | if (typeof Component !== 'function') { 56 | throw new Error( 57 | `[Wormhole]: Expected function, encountered ${typeof Component}. Did you forget to mark your Wormhole as a default export?` 58 | ); 59 | } 60 | return Component; 61 | }; 62 | 63 | const buildRequestOpenUri = ({ 64 | cache, 65 | buildRequestForUri, 66 | verify, 67 | shouldCreateComponent, 68 | shouldComplete, 69 | }: { 70 | readonly cache: WormholeComponentCache, 71 | readonly buildRequestForUri: (config: AxiosRequestConfig) => AxiosPromise; 72 | readonly verify: (response: AxiosResponse) => Promise; 73 | readonly shouldCreateComponent: (src: string) => Promise; 74 | readonly shouldComplete: (uri: string, error?: Error) => void; 75 | }) => async (uri: string) => { 76 | try { 77 | const result = await buildRequestForUri({ 78 | url: uri, 79 | method: 'get', 80 | }); 81 | const { data } = result; 82 | if (typeof data !== 'string') { 83 | throw new Error(`[Wormhole]: Expected string data, encountered ${typeof data}.`); 84 | } 85 | if (await verify(result) !== true) { 86 | throw new Error(`[Wormhole]: Failed to verify "${uri}".`); 87 | } 88 | const Component = await shouldCreateComponent(data); 89 | Object.assign(cache, { [uri]: Component }); 90 | return shouldComplete(uri); 91 | } catch (e) { 92 | Object.assign(cache, { [uri]: null }); 93 | if (typeof e === 'string') { 94 | return shouldComplete(uri, new Error(e)); 95 | } else if (typeof e.message === 'string') { 96 | return shouldComplete(uri, new Error(`${e.message}`)); 97 | } 98 | return shouldComplete(uri, e); 99 | } 100 | }; 101 | 102 | const buildOpenUri = ({ 103 | cache, 104 | tasks, 105 | shouldRequestOpenUri, 106 | }: { 107 | readonly cache: WormholeComponentCache; 108 | readonly tasks: WormholeTasks; 109 | readonly shouldRequestOpenUri: (uri: string) => void; 110 | }) => (uri: string, callback: PromiseCallback): void => { 111 | const { [uri]: Component } = cache; 112 | const { resolve, reject } = callback; 113 | if (Component === null) { 114 | return reject( 115 | new Error(`[Wormhole]: Component at uri "${uri}" could not be instantiated.`) 116 | ); 117 | } else if (typeof Component === 'function') { 118 | return resolve(Component); 119 | } 120 | 121 | const { [uri]: queue } = tasks; 122 | if (Array.isArray(queue)) { 123 | queue.push(callback); 124 | return; 125 | } 126 | 127 | Object.assign(tasks, { [uri]: [callback] }); 128 | 129 | return shouldRequestOpenUri(uri); 130 | }; 131 | 132 | const buildOpenString = ({ 133 | shouldCreateComponent, 134 | }: { 135 | readonly shouldCreateComponent: (src: string) => Promise; 136 | }) => async (src: string) => { 137 | return shouldCreateComponent(src); 138 | }; 139 | 140 | const buildOpenWormhole = ({ 141 | shouldOpenString, 142 | shouldOpenUri, 143 | }: { 144 | readonly shouldOpenString: (src: string) => Promise; 145 | readonly shouldOpenUri: ( 146 | uri: string, 147 | callback: PromiseCallback 148 | ) => void; 149 | }) => async (source: WormholeSource, options: WormholeOptions): Promise => { 150 | const { dangerouslySetInnerJSX } = options; 151 | if (typeof source === 'string') { 152 | if (dangerouslySetInnerJSX === true) { 153 | return shouldOpenString(source as string); 154 | } 155 | throw new Error( 156 | `[Wormhole]: Attempted to instantiate a Wormhole using a string, but dangerouslySetInnerJSX was not true.` 157 | ); 158 | } else if (source && typeof source === 'object') { 159 | const { uri } = source; 160 | if (typeof uri === 'string') { 161 | return new Promise( 162 | (resolve, reject) => shouldOpenUri(uri, { resolve, reject }), 163 | ); 164 | } 165 | } 166 | throw new Error(`[Wormhole]: Expected valid source, encountered ${typeof source}.`); 167 | }; 168 | 169 | export default function createWormhole({ 170 | buildRequestForUri = (config: AxiosRequestConfig) => axios(config), 171 | global = defaultGlobal, 172 | verify, 173 | }: WormholeContextConfig) { 174 | if (typeof verify !== 'function') { 175 | throw new Error( 176 | '[Wormhole]: To create a Wormhole, you **must** pass a verify() function.', 177 | ); 178 | } 179 | 180 | const cache: WormholeComponentCache = {}; 181 | const tasks: WormholeTasks = {}; 182 | 183 | const shouldComplete = buildCompletionHandler(cache, tasks); 184 | const shouldCreateComponent = buildCreateComponent(global); 185 | const shouldRequestOpenUri = buildRequestOpenUri({ 186 | cache, 187 | buildRequestForUri, 188 | verify, 189 | shouldCreateComponent, 190 | shouldComplete, 191 | }); 192 | const shouldOpenUri = buildOpenUri({ 193 | cache, 194 | tasks, 195 | shouldRequestOpenUri, 196 | }); 197 | const shouldOpenString = buildOpenString({ 198 | shouldCreateComponent, 199 | }); 200 | 201 | const shouldOpenWormhole = buildOpenWormhole({ 202 | shouldOpenUri, 203 | shouldOpenString, 204 | }); 205 | 206 | const Wormhole = (props: WormholeProps) => ( 207 | 208 | ); 209 | 210 | const preload = async (uri: string): Promise => { 211 | await shouldOpenWormhole({ uri }, { dangerouslySetInnerJSX: false }) 212 | }; 213 | 214 | return Object.freeze({ 215 | Wormhole, 216 | preload, 217 | }); 218 | } 219 | -------------------------------------------------------------------------------- /src/constants/index.ts: -------------------------------------------------------------------------------- 1 | export { default as createWormhole } from './createWormhole'; 2 | -------------------------------------------------------------------------------- /src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export { default as useForceUpdate } from './useForceUpdate'; 2 | -------------------------------------------------------------------------------- /src/hooks/useForceUpdate.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export default function useForceUpdate(): { 4 | readonly forceUpdate: () => void; 5 | } { 6 | const [, setState] = React.useState(false); 7 | const forceUpdate = React.useCallback(() => { 8 | setState(e => !e); 9 | }, [setState]); 10 | return { forceUpdate }; 11 | } 12 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './@types'; 2 | export * from './components'; 3 | export * from './constants'; 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "allowSyntheticDefaultImports": true, 5 | "esModuleInterop": true, 6 | "isolatedModules": true, 7 | "jsx": "react", 8 | "declaration": true, 9 | "lib": ["es6"], 10 | "moduleResolution": "node", 11 | "removeComments": true, 12 | "strict": true, 13 | "target": "esnext", 14 | "outDir": "dist" 15 | }, 16 | "exclude": [ 17 | "node_modules", 18 | "babel.config.js", 19 | "metro.config.js", 20 | "jest.config.js", 21 | "example/", 22 | ] 23 | } --------------------------------------------------------------------------------