├── .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 | }
--------------------------------------------------------------------------------