@tinasoft/miniapp-host-demo 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +85 -2
- package/lib/Header.d.ts +5 -0
- package/lib/Header.js +21 -0
- package/lib/MiniAppWebView.d.ts +17 -0
- package/lib/MiniAppWebView.js +70 -0
- package/lib/RnMiniAppHost.d.ts +21 -0
- package/lib/RnMiniAppHost.js +52 -0
- package/lib/bridge.d.ts +1 -0
- package/lib/bridge.js +50 -0
- package/lib/index.d.ts +7 -0
- package/lib/index.js +13 -0
- package/lib/log.d.ts +5 -0
- package/lib/log.js +9 -0
- package/lib/types.d.ts +18 -0
- package/lib/types.js +2 -0
- package/package.json +49 -4
- package/src/Header.tsx +40 -0
- package/src/MiniAppWebView.tsx +126 -0
- package/src/RnMiniAppHost.tsx +111 -0
- package/src/bridge.ts +47 -0
- package/src/index.ts +7 -0
- package/src/log.ts +12 -0
- package/src/types.ts +20 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 HUNGTV
|
|
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.
|
package/README.md
CHANGED
|
@@ -1,3 +1,86 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @tinasoft/miniapp-host-demo
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Open mini apps inside your React Native app, super-app style.
|
|
4
|
+
|
|
5
|
+
- **Web mini apps** in a WebView, with a token bridge that only answers the mini app's own origin, navigation locked to that origin, and the mini app's `console` / JS errors forwarded to the host.
|
|
6
|
+
- **React Native mini apps** loaded at runtime with [Re.Pack](https://re-pack.dev) Module Federation, receiving the session as props, with render errors contained.
|
|
7
|
+
|
|
8
|
+
Tokens are never put in URLs or pre-injected into pages, and are masked (`***`) in every log line.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
npm install @tinasoft/miniapp-host-demo react-native-webview react-native-safe-area-context
|
|
14
|
+
cd ios && pod install
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Wrap your app in `SafeAreaProvider` from `react-native-safe-area-context`.
|
|
18
|
+
|
|
19
|
+
## Web mini app
|
|
20
|
+
|
|
21
|
+
```tsx
|
|
22
|
+
import { MiniAppWebView } from '@tinasoft/miniapp-host-demo';
|
|
23
|
+
|
|
24
|
+
<MiniAppWebView
|
|
25
|
+
app={{
|
|
26
|
+
id: 'profile',
|
|
27
|
+
name: 'Profile',
|
|
28
|
+
url: 'https://profile.example.com/',
|
|
29
|
+
origin: 'https://profile.example.com',
|
|
30
|
+
}}
|
|
31
|
+
getSession={async appId => ({ token, expiresAt, user })}
|
|
32
|
+
onLog={(tag, level, ...args) => console[level](`[${tag}]`, ...args)}
|
|
33
|
+
onClose={() => setOpen(null)}
|
|
34
|
+
/>
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Inside the web page:
|
|
38
|
+
|
|
39
|
+
```js
|
|
40
|
+
const { token, user } = await window.host.getSession();
|
|
41
|
+
console.log('hello host'); // shows up in the host as [mini:profile] hello host
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## React Native mini app (Re.Pack)
|
|
45
|
+
|
|
46
|
+
The host must be built with Re.Pack and declare the remote in `rspack.config.mjs`
|
|
47
|
+
(`new Repack.plugins.ModuleFederationPluginV2({ remotes: { wallet: 'wallet@https://cdn.example.com/wallet/ios/mf-manifest.json' } })`).
|
|
48
|
+
|
|
49
|
+
```tsx
|
|
50
|
+
import { RnMiniAppHost } from '@tinasoft/miniapp-host-demo';
|
|
51
|
+
|
|
52
|
+
const loadWallet = () => import('wallet/App'); // keep it outside the component
|
|
53
|
+
|
|
54
|
+
<RnMiniAppHost
|
|
55
|
+
app={{ id: 'wallet', name: 'Wallet' }}
|
|
56
|
+
load={loadWallet}
|
|
57
|
+
getSession={getSession}
|
|
58
|
+
onClose={() => setOpen(null)}
|
|
59
|
+
/>
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The mini app exposes `./App`, a component that receives `MiniAppProps`:
|
|
63
|
+
|
|
64
|
+
```tsx
|
|
65
|
+
import type { MiniAppProps } from '@tinasoft/miniapp-host-demo';
|
|
66
|
+
|
|
67
|
+
export default function App({ session, log }: MiniAppProps) {
|
|
68
|
+
log('log', 'opened by', session.user.id);
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## API
|
|
74
|
+
|
|
75
|
+
| Export | What it is |
|
|
76
|
+
| --- | --- |
|
|
77
|
+
| `MiniAppWebView` | Screen that hosts a web mini app (`app`, `getSession`, `onLog?`, `onClose?`, `closeLabel?`) |
|
|
78
|
+
| `RnMiniAppHost` | Screen that hosts a Re.Pack mini app (`app`, `load`, `getSession`, `onLog?`, `onClose?`, `closeLabel?`) |
|
|
79
|
+
| `isAllowedUrl(app, url)` | Origin check used for messages and navigation |
|
|
80
|
+
| `redact(value)` | `JSON.stringify` with every `token` field masked |
|
|
81
|
+
| `consoleLog` | Default `onLog`: console with `[tag]` prefix and masking |
|
|
82
|
+
| `BRIDGE_SCRIPT` | The script injected into web mini apps |
|
|
83
|
+
|
|
84
|
+
## License
|
|
85
|
+
|
|
86
|
+
MIT
|
package/lib/Header.d.ts
ADDED
package/lib/Header.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.Header = Header;
|
|
4
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
5
|
+
const react_native_1 = require("react-native");
|
|
6
|
+
function Header({ title, onClose, closeLabel, }) {
|
|
7
|
+
return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.header, children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.title, children: title }), onClose && ((0, jsx_runtime_1.jsx)(react_native_1.Pressable, { onPress: onClose, hitSlop: 12, accessibilityRole: "button", accessibilityLabel: closeLabel, children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.close, children: closeLabel }) }))] }));
|
|
8
|
+
}
|
|
9
|
+
const styles = react_native_1.StyleSheet.create({
|
|
10
|
+
header: {
|
|
11
|
+
flexDirection: 'row',
|
|
12
|
+
alignItems: 'center',
|
|
13
|
+
justifyContent: 'space-between',
|
|
14
|
+
paddingHorizontal: 16,
|
|
15
|
+
height: 48,
|
|
16
|
+
borderBottomWidth: react_native_1.StyleSheet.hairlineWidth,
|
|
17
|
+
borderBottomColor: '#ccc',
|
|
18
|
+
},
|
|
19
|
+
title: { fontSize: 17, fontWeight: '600', color: '#111' },
|
|
20
|
+
close: { fontSize: 16, color: '#2563eb' },
|
|
21
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { GetSession, OnLog } from './types';
|
|
2
|
+
export type WebMiniApp = {
|
|
3
|
+
id: string;
|
|
4
|
+
name: string;
|
|
5
|
+
url: string;
|
|
6
|
+
/** Chỉ origin này được nhận session và được điều hướng tới, vd 'https://wallet.example.com'. */
|
|
7
|
+
origin: string;
|
|
8
|
+
};
|
|
9
|
+
export declare const isAllowedUrl: (app: Pick<WebMiniApp, "origin">, url: string) => boolean;
|
|
10
|
+
export type MiniAppWebViewProps = {
|
|
11
|
+
app: WebMiniApp;
|
|
12
|
+
getSession: GetSession;
|
|
13
|
+
onLog?: OnLog;
|
|
14
|
+
onClose?: () => void;
|
|
15
|
+
closeLabel?: string;
|
|
16
|
+
};
|
|
17
|
+
export declare const MiniAppWebView: import("react").MemoExoticComponent<({ app, getSession, onLog, onClose, closeLabel, }: MiniAppWebViewProps) => import("react").JSX.Element>;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MiniAppWebView = exports.isAllowedUrl = void 0;
|
|
4
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
5
|
+
const react_1 = require("react");
|
|
6
|
+
const react_native_1 = require("react-native");
|
|
7
|
+
const react_native_safe_area_context_1 = require("react-native-safe-area-context");
|
|
8
|
+
const react_native_webview_1 = require("react-native-webview");
|
|
9
|
+
const bridge_1 = require("./bridge");
|
|
10
|
+
const Header_1 = require("./Header");
|
|
11
|
+
const log_1 = require("./log");
|
|
12
|
+
// Thêm '/' để 'https://a.com.evil.com' không lọt qua startsWith.
|
|
13
|
+
const isAllowedUrl = (app, url) => url.startsWith(app.origin + '/');
|
|
14
|
+
exports.isAllowedUrl = isAllowedUrl;
|
|
15
|
+
// memo + props ổn định: WebView không bị render lại/tải lại khi host re-render.
|
|
16
|
+
exports.MiniAppWebView = (0, react_1.memo)(function Screen({ app, getSession, onLog = log_1.consoleLog, onClose, closeLabel = 'Đóng', }) {
|
|
17
|
+
const insets = (0, react_native_safe_area_context_1.useSafeAreaInsets)();
|
|
18
|
+
// WebView<{}>: kiểu mặc định của react-native-webview 14 thành never với TS 6.
|
|
19
|
+
const ref = (0, react_1.useRef)(null);
|
|
20
|
+
const tag = `mini:${app.id}`;
|
|
21
|
+
const source = (0, react_1.useMemo)(() => ({ uri: app.url }), [app.url]);
|
|
22
|
+
const whitelist = (0, react_1.useMemo)(() => [app.origin], [app.origin]);
|
|
23
|
+
const onMessage = (0, react_1.useCallback)(async (e) => {
|
|
24
|
+
// Chỉ tin message từ đúng origin của mini app.
|
|
25
|
+
if (!(0, exports.isAllowedUrl)(app, e.nativeEvent.url)) {
|
|
26
|
+
onLog('host', 'warn', 'bỏ qua message từ origin lạ:', e.nativeEvent.url);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
let msg;
|
|
30
|
+
try {
|
|
31
|
+
msg = JSON.parse(e.nativeEvent.data);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (msg?.type === 'log' && Array.isArray(msg.items)) {
|
|
37
|
+
msg.items.forEach((i) => onLog(tag, i.level === 'warn' || i.level === 'error' ? i.level : 'log', String(i.msg)));
|
|
38
|
+
}
|
|
39
|
+
else if (msg?.type === 'getSession') {
|
|
40
|
+
try {
|
|
41
|
+
const session = await getSession(app.id);
|
|
42
|
+
onLog('host', 'log', `gửi session cho ${app.id}:`, session);
|
|
43
|
+
ref.current?.injectJavaScript(`window.host && window.host._deliver(${JSON.stringify(session)}); true;`);
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
onLog('host', 'error', `không lấy được session cho ${app.id}:`, String(err));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}, [app, tag, getSession, onLog]);
|
|
50
|
+
// Chặn điều hướng ra ngoài origin; link lạ mở bằng trình duyệt hệ thống.
|
|
51
|
+
const onShouldStart = (0, react_1.useCallback)((req) => {
|
|
52
|
+
if ((0, exports.isAllowedUrl)(app, req.url)) {
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
onLog('host', 'warn', 'chặn điều hướng:', req.url);
|
|
56
|
+
if (/^https?:/.test(req.url)) {
|
|
57
|
+
react_native_1.Linking.openURL(req.url);
|
|
58
|
+
}
|
|
59
|
+
return false;
|
|
60
|
+
}, [app, onLog]);
|
|
61
|
+
// Dùng lâu, thiếu RAM thì OS kill tiến trình web (màn trắng) → tải lại.
|
|
62
|
+
const reload = (0, react_1.useCallback)(() => {
|
|
63
|
+
onLog('host', 'warn', `tiến trình web của ${app.id} bị kill, tải lại`);
|
|
64
|
+
ref.current?.reload();
|
|
65
|
+
}, [app.id, onLog]);
|
|
66
|
+
return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: [styles.container, { paddingTop: insets.top }], children: [(0, jsx_runtime_1.jsx)(Header_1.Header, { title: app.name, onClose: onClose, closeLabel: closeLabel }), (0, jsx_runtime_1.jsx)(react_native_webview_1.WebView, { ref: ref, source: source, originWhitelist: whitelist, injectedJavaScriptBeforeContentLoaded: bridge_1.BRIDGE_SCRIPT, onMessage: onMessage, onShouldStartLoadWithRequest: onShouldStart, onContentProcessDidTerminate: reload, onRenderProcessGone: reload, onLoadStart: () => onLog('host', 'log', `mở ${app.id}: ${app.url}`), onError: e => onLog('host', 'error', 'WebView lỗi:', e.nativeEvent.description), webviewDebuggingEnabled: __DEV__ })] }));
|
|
67
|
+
});
|
|
68
|
+
const styles = react_native_1.StyleSheet.create({
|
|
69
|
+
container: { flex: 1, backgroundColor: '#fff' },
|
|
70
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { ComponentType } from 'react';
|
|
2
|
+
import type { GetSession, MiniAppProps, OnLog } from './types';
|
|
3
|
+
export type RnMiniApp = {
|
|
4
|
+
id: string;
|
|
5
|
+
name: string;
|
|
6
|
+
};
|
|
7
|
+
export type RnMiniAppHostProps = {
|
|
8
|
+
app: RnMiniApp;
|
|
9
|
+
/**
|
|
10
|
+
* Tải module mini app, vd `() => import('wallet/App')` với remote khai báo
|
|
11
|
+
* trong rspack.config của host. Truyền hàm cố định (khai báo ngoài component).
|
|
12
|
+
*/
|
|
13
|
+
load: () => Promise<{
|
|
14
|
+
default: ComponentType<MiniAppProps>;
|
|
15
|
+
}>;
|
|
16
|
+
getSession: GetSession;
|
|
17
|
+
onLog?: OnLog;
|
|
18
|
+
onClose?: () => void;
|
|
19
|
+
closeLabel?: string;
|
|
20
|
+
};
|
|
21
|
+
export declare function RnMiniAppHost({ app, load, getSession, onLog, onClose, closeLabel, }: RnMiniAppHostProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.RnMiniAppHost = RnMiniAppHost;
|
|
4
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
5
|
+
const react_1 = require("react");
|
|
6
|
+
const react_native_1 = require("react-native");
|
|
7
|
+
const react_native_safe_area_context_1 = require("react-native-safe-area-context");
|
|
8
|
+
const Header_1 = require("./Header");
|
|
9
|
+
const log_1 = require("./log");
|
|
10
|
+
// Mini app chạy chung runtime với host: lỗi render của nó phải bị chặn ở đây,
|
|
11
|
+
// nếu không sẽ làm sập cả app cha.
|
|
12
|
+
class Boundary extends react_1.Component {
|
|
13
|
+
constructor() {
|
|
14
|
+
super(...arguments);
|
|
15
|
+
this.state = {};
|
|
16
|
+
}
|
|
17
|
+
static getDerivedStateFromError(error) {
|
|
18
|
+
return { error };
|
|
19
|
+
}
|
|
20
|
+
componentDidCatch(error) {
|
|
21
|
+
this.props.onLog(this.props.tag, 'error', error.message);
|
|
22
|
+
}
|
|
23
|
+
render() {
|
|
24
|
+
return this.state.error ? ((0, jsx_runtime_1.jsxs)(react_native_1.Text, { style: styles.error, children: ["Kh\u00F4ng t\u1EA3i \u0111\u01B0\u1EE3c mini app: ", this.state.error.message] })) : (this.props.children);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function RnMiniAppHost({ app, load, getSession, onLog = log_1.consoleLog, onClose, closeLabel = 'Đóng', }) {
|
|
28
|
+
const insets = (0, react_native_safe_area_context_1.useSafeAreaInsets)();
|
|
29
|
+
const tag = `mini:${app.id}`;
|
|
30
|
+
// Bundle chỉ được tải khi mini app được mở lần đầu.
|
|
31
|
+
const Remote = (0, react_1.useMemo)(() => (0, react_1.lazy)(load), [load]);
|
|
32
|
+
const [session, setSession] = (0, react_1.useState)(null);
|
|
33
|
+
(0, react_1.useEffect)(() => {
|
|
34
|
+
let alive = true;
|
|
35
|
+
Promise.resolve(getSession(app.id)).then(s => {
|
|
36
|
+
if (!alive)
|
|
37
|
+
return;
|
|
38
|
+
onLog('host', 'log', `gửi session cho ${app.id}:`, s);
|
|
39
|
+
setSession(s);
|
|
40
|
+
}, err => onLog('host', 'error', `không lấy được session cho ${app.id}:`, String(err)));
|
|
41
|
+
return () => {
|
|
42
|
+
alive = false;
|
|
43
|
+
};
|
|
44
|
+
}, [app.id, getSession, onLog]);
|
|
45
|
+
const miniLog = (0, react_1.useCallback)((level, ...args) => onLog(tag, level, ...args), [tag, onLog]);
|
|
46
|
+
return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: [styles.container, { paddingTop: insets.top }], children: [(0, jsx_runtime_1.jsx)(Header_1.Header, { title: app.name, onClose: onClose, closeLabel: closeLabel }), (0, jsx_runtime_1.jsx)(Boundary, { tag: tag, onLog: onLog, children: (0, jsx_runtime_1.jsx)(react_1.Suspense, { fallback: (0, jsx_runtime_1.jsx)(react_native_1.ActivityIndicator, { style: styles.loading }), children: session ? ((0, jsx_runtime_1.jsx)(Remote, { session: session, log: miniLog })) : ((0, jsx_runtime_1.jsx)(react_native_1.ActivityIndicator, { style: styles.loading })) }) })] }));
|
|
47
|
+
}
|
|
48
|
+
const styles = react_native_1.StyleSheet.create({
|
|
49
|
+
container: { flex: 1, backgroundColor: '#fff' },
|
|
50
|
+
loading: { marginTop: 40 },
|
|
51
|
+
error: { margin: 16, color: '#dc2626' },
|
|
52
|
+
});
|
package/lib/bridge.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const BRIDGE_SCRIPT = "\n(function () {\n if (window.host) return;\n var queue = [];\n var waiting = [];\n function send(m) { window.ReactNativeWebView.postMessage(JSON.stringify(m)); }\n function fmt(a) {\n if (typeof a === 'string') return a;\n if (a instanceof Error) return a.stack || a.message;\n try { return JSON.stringify(a); } catch (e) { return String(a); }\n }\n function push(level, args) {\n if (queue.length >= 50) return; // ch\u1ED1ng spam l\u00E0m ngh\u1EBDn bridge\n queue.push({ level: level, msg: [].map.call(args, fmt).join(' ').slice(0, 1000) });\n }\n ['log', 'warn', 'error'].forEach(function (level) {\n var orig = console[level];\n console[level] = function () { orig.apply(console, arguments); push(level, arguments); };\n });\n window.addEventListener('error', function (e) {\n push('error', [e.message + ' (' + e.filename + ':' + e.lineno + ')']);\n });\n window.addEventListener('unhandledrejection', function (e) {\n push('error', ['Unhandled rejection:', e.reason]);\n });\n setInterval(function () {\n if (queue.length) { send({ type: 'log', items: queue }); queue = []; }\n }, 300);\n window.host = {\n getSession: function () {\n return new Promise(function (resolve) {\n waiting.push(resolve);\n if (waiting.length === 1) send({ type: 'getSession' });\n });\n },\n _deliver: function (session) {\n var w = waiting; waiting = [];\n w.forEach(function (resolve) { resolve(session); });\n },\n };\n})();\ntrue;\n";
|
package/lib/bridge.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.BRIDGE_SCRIPT = void 0;
|
|
4
|
+
// Script chạy trong WebView trước khi trang load.
|
|
5
|
+
// - Gom console.log/warn/error + lỗi JS, gửi về host theo lô mỗi 300ms.
|
|
6
|
+
// - window.host.getSession(): Promise trả { token, expiresAt, user }.
|
|
7
|
+
// Không chứa token: host chỉ trả session sau khi kiểm tra origin.
|
|
8
|
+
exports.BRIDGE_SCRIPT = `
|
|
9
|
+
(function () {
|
|
10
|
+
if (window.host) return;
|
|
11
|
+
var queue = [];
|
|
12
|
+
var waiting = [];
|
|
13
|
+
function send(m) { window.ReactNativeWebView.postMessage(JSON.stringify(m)); }
|
|
14
|
+
function fmt(a) {
|
|
15
|
+
if (typeof a === 'string') return a;
|
|
16
|
+
if (a instanceof Error) return a.stack || a.message;
|
|
17
|
+
try { return JSON.stringify(a); } catch (e) { return String(a); }
|
|
18
|
+
}
|
|
19
|
+
function push(level, args) {
|
|
20
|
+
if (queue.length >= 50) return; // chống spam làm nghẽn bridge
|
|
21
|
+
queue.push({ level: level, msg: [].map.call(args, fmt).join(' ').slice(0, 1000) });
|
|
22
|
+
}
|
|
23
|
+
['log', 'warn', 'error'].forEach(function (level) {
|
|
24
|
+
var orig = console[level];
|
|
25
|
+
console[level] = function () { orig.apply(console, arguments); push(level, arguments); };
|
|
26
|
+
});
|
|
27
|
+
window.addEventListener('error', function (e) {
|
|
28
|
+
push('error', [e.message + ' (' + e.filename + ':' + e.lineno + ')']);
|
|
29
|
+
});
|
|
30
|
+
window.addEventListener('unhandledrejection', function (e) {
|
|
31
|
+
push('error', ['Unhandled rejection:', e.reason]);
|
|
32
|
+
});
|
|
33
|
+
setInterval(function () {
|
|
34
|
+
if (queue.length) { send({ type: 'log', items: queue }); queue = []; }
|
|
35
|
+
}, 300);
|
|
36
|
+
window.host = {
|
|
37
|
+
getSession: function () {
|
|
38
|
+
return new Promise(function (resolve) {
|
|
39
|
+
waiting.push(resolve);
|
|
40
|
+
if (waiting.length === 1) send({ type: 'getSession' });
|
|
41
|
+
});
|
|
42
|
+
},
|
|
43
|
+
_deliver: function (session) {
|
|
44
|
+
var w = waiting; waiting = [];
|
|
45
|
+
w.forEach(function (resolve) { resolve(session); });
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
})();
|
|
49
|
+
true;
|
|
50
|
+
`;
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { BRIDGE_SCRIPT } from './bridge';
|
|
2
|
+
export { consoleLog, redact } from './log';
|
|
3
|
+
export { isAllowedUrl, MiniAppWebView } from './MiniAppWebView';
|
|
4
|
+
export type { MiniAppWebViewProps, WebMiniApp } from './MiniAppWebView';
|
|
5
|
+
export { RnMiniAppHost } from './RnMiniAppHost';
|
|
6
|
+
export type { RnMiniApp, RnMiniAppHostProps } from './RnMiniAppHost';
|
|
7
|
+
export type { GetSession, LogLevel, MiniAppProps, OnLog, Session } from './types';
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.RnMiniAppHost = exports.MiniAppWebView = exports.isAllowedUrl = exports.redact = exports.consoleLog = exports.BRIDGE_SCRIPT = void 0;
|
|
4
|
+
var bridge_1 = require("./bridge");
|
|
5
|
+
Object.defineProperty(exports, "BRIDGE_SCRIPT", { enumerable: true, get: function () { return bridge_1.BRIDGE_SCRIPT; } });
|
|
6
|
+
var log_1 = require("./log");
|
|
7
|
+
Object.defineProperty(exports, "consoleLog", { enumerable: true, get: function () { return log_1.consoleLog; } });
|
|
8
|
+
Object.defineProperty(exports, "redact", { enumerable: true, get: function () { return log_1.redact; } });
|
|
9
|
+
var MiniAppWebView_1 = require("./MiniAppWebView");
|
|
10
|
+
Object.defineProperty(exports, "isAllowedUrl", { enumerable: true, get: function () { return MiniAppWebView_1.isAllowedUrl; } });
|
|
11
|
+
Object.defineProperty(exports, "MiniAppWebView", { enumerable: true, get: function () { return MiniAppWebView_1.MiniAppWebView; } });
|
|
12
|
+
var RnMiniAppHost_1 = require("./RnMiniAppHost");
|
|
13
|
+
Object.defineProperty(exports, "RnMiniAppHost", { enumerable: true, get: function () { return RnMiniAppHost_1.RnMiniAppHost; } });
|
package/lib/log.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { OnLog } from './types';
|
|
2
|
+
/** JSON.stringify nhưng che mọi trường `token`, kể cả lồng sâu. */
|
|
3
|
+
export declare const redact: (value: unknown) => string;
|
|
4
|
+
/** Log mặc định: in ra console với prefix [tag], token bị che. */
|
|
5
|
+
export declare const consoleLog: OnLog;
|
package/lib/log.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.consoleLog = exports.redact = void 0;
|
|
4
|
+
/** JSON.stringify nhưng che mọi trường `token`, kể cả lồng sâu. */
|
|
5
|
+
const redact = (value) => JSON.stringify(value, (key, v) => (key === 'token' ? '***' : v));
|
|
6
|
+
exports.redact = redact;
|
|
7
|
+
/** Log mặc định: in ra console với prefix [tag], token bị che. */
|
|
8
|
+
const consoleLog = (tag, level, ...args) => console[level](`[${tag}]`, ...args.map(a => (typeof a === 'string' ? a : (0, exports.redact)(a))));
|
|
9
|
+
exports.consoleLog = consoleLog;
|
package/lib/types.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type LogLevel = 'log' | 'warn' | 'error';
|
|
2
|
+
/** Dữ liệu host cấp cho mini app. `user` tuỳ app, tối thiểu có `id`. */
|
|
3
|
+
export type Session = {
|
|
4
|
+
token: string;
|
|
5
|
+
expiresAt: number;
|
|
6
|
+
user: {
|
|
7
|
+
id: string;
|
|
8
|
+
} & Record<string, unknown>;
|
|
9
|
+
};
|
|
10
|
+
/** Host tự quyết lấy session thế nào (token giả, hoặc gọi backend đổi token). */
|
|
11
|
+
export type GetSession = (miniAppId: string) => Session | Promise<Session>;
|
|
12
|
+
/** Nơi host nhận log của chính nó (`tag = 'host'`) và của mini app (`tag = 'mini:<id>'`). */
|
|
13
|
+
export type OnLog = (tag: string, level: LogLevel, ...args: unknown[]) => void;
|
|
14
|
+
/** Props mà mini app React Native nhận từ host. */
|
|
15
|
+
export type MiniAppProps = {
|
|
16
|
+
session: Session;
|
|
17
|
+
log: (level: LogLevel, ...args: unknown[]) => void;
|
|
18
|
+
};
|
package/lib/types.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,51 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tinasoft/miniapp-host-demo",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Host mini apps inside a React Native app: WebView mini apps with a secure token bridge and log forwarding, and React Native mini apps loaded with Re.Pack / Module Federation.",
|
|
5
|
+
"main": "lib/index.js",
|
|
6
|
+
"types": "lib/index.d.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"lib",
|
|
9
|
+
"src",
|
|
10
|
+
"README.md",
|
|
11
|
+
"LICENSE"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsc -p tsconfig.json",
|
|
15
|
+
"prepack": "npm run build"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"react-native",
|
|
19
|
+
"mini-app",
|
|
20
|
+
"super-app",
|
|
21
|
+
"webview",
|
|
22
|
+
"module-federation",
|
|
23
|
+
"repack"
|
|
24
|
+
],
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"react": ">=18",
|
|
28
|
+
"react-native": ">=0.74",
|
|
29
|
+
"react-native-safe-area-context": ">=4",
|
|
30
|
+
"react-native-webview": ">=13"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/react": "^19.2.0",
|
|
34
|
+
"react": "19.2.3",
|
|
35
|
+
"react-native": "0.87.1",
|
|
36
|
+
"react-native-safe-area-context": "^5.5.2",
|
|
37
|
+
"react-native-webview": "^14.0.1",
|
|
38
|
+
"typescript": "^6.0.3"
|
|
39
|
+
},
|
|
40
|
+
"repository": {
|
|
41
|
+
"type": "git",
|
|
42
|
+
"url": "git+https://github.com/hungto228/miniapp-host-demo.git"
|
|
43
|
+
},
|
|
44
|
+
"homepage": "https://github.com/hungto228/miniapp-host-demo#readme",
|
|
45
|
+
"bugs": {
|
|
46
|
+
"url": "https://github.com/hungto228/miniapp-host-demo/issues"
|
|
47
|
+
},
|
|
48
|
+
"publishConfig": {
|
|
49
|
+
"access": "public"
|
|
50
|
+
}
|
|
51
|
+
}
|
package/src/Header.tsx
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
|
2
|
+
|
|
3
|
+
export function Header({
|
|
4
|
+
title,
|
|
5
|
+
onClose,
|
|
6
|
+
closeLabel,
|
|
7
|
+
}: {
|
|
8
|
+
title: string;
|
|
9
|
+
onClose?: () => void;
|
|
10
|
+
closeLabel: string;
|
|
11
|
+
}) {
|
|
12
|
+
return (
|
|
13
|
+
<View style={styles.header}>
|
|
14
|
+
<Text style={styles.title}>{title}</Text>
|
|
15
|
+
{onClose && (
|
|
16
|
+
<Pressable
|
|
17
|
+
onPress={onClose}
|
|
18
|
+
hitSlop={12}
|
|
19
|
+
accessibilityRole="button"
|
|
20
|
+
accessibilityLabel={closeLabel}>
|
|
21
|
+
<Text style={styles.close}>{closeLabel}</Text>
|
|
22
|
+
</Pressable>
|
|
23
|
+
)}
|
|
24
|
+
</View>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const styles = StyleSheet.create({
|
|
29
|
+
header: {
|
|
30
|
+
flexDirection: 'row',
|
|
31
|
+
alignItems: 'center',
|
|
32
|
+
justifyContent: 'space-between',
|
|
33
|
+
paddingHorizontal: 16,
|
|
34
|
+
height: 48,
|
|
35
|
+
borderBottomWidth: StyleSheet.hairlineWidth,
|
|
36
|
+
borderBottomColor: '#ccc',
|
|
37
|
+
},
|
|
38
|
+
title: { fontSize: 17, fontWeight: '600', color: '#111' },
|
|
39
|
+
close: { fontSize: 16, color: '#2563eb' },
|
|
40
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { memo, useCallback, useMemo, useRef } from 'react';
|
|
2
|
+
import { Linking, StyleSheet, View } from 'react-native';
|
|
3
|
+
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
4
|
+
import { WebView, WebViewMessageEvent } from 'react-native-webview';
|
|
5
|
+
import { BRIDGE_SCRIPT } from './bridge';
|
|
6
|
+
import { Header } from './Header';
|
|
7
|
+
import { consoleLog } from './log';
|
|
8
|
+
import type { GetSession, OnLog } from './types';
|
|
9
|
+
|
|
10
|
+
export type WebMiniApp = {
|
|
11
|
+
id: string;
|
|
12
|
+
name: string;
|
|
13
|
+
url: string;
|
|
14
|
+
/** Chỉ origin này được nhận session và được điều hướng tới, vd 'https://wallet.example.com'. */
|
|
15
|
+
origin: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// Thêm '/' để 'https://a.com.evil.com' không lọt qua startsWith.
|
|
19
|
+
export const isAllowedUrl = (app: Pick<WebMiniApp, 'origin'>, url: string) =>
|
|
20
|
+
url.startsWith(app.origin + '/');
|
|
21
|
+
|
|
22
|
+
export type MiniAppWebViewProps = {
|
|
23
|
+
app: WebMiniApp;
|
|
24
|
+
getSession: GetSession;
|
|
25
|
+
onLog?: OnLog;
|
|
26
|
+
onClose?: () => void;
|
|
27
|
+
closeLabel?: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// memo + props ổn định: WebView không bị render lại/tải lại khi host re-render.
|
|
31
|
+
export const MiniAppWebView = memo(function Screen({
|
|
32
|
+
app,
|
|
33
|
+
getSession,
|
|
34
|
+
onLog = consoleLog,
|
|
35
|
+
onClose,
|
|
36
|
+
closeLabel = 'Đóng',
|
|
37
|
+
}: MiniAppWebViewProps) {
|
|
38
|
+
const insets = useSafeAreaInsets();
|
|
39
|
+
// WebView<{}>: kiểu mặc định của react-native-webview 14 thành never với TS 6.
|
|
40
|
+
const ref = useRef<WebView<{}>>(null);
|
|
41
|
+
const tag = `mini:${app.id}`;
|
|
42
|
+
const source = useMemo(() => ({ uri: app.url }), [app.url]);
|
|
43
|
+
const whitelist = useMemo(() => [app.origin], [app.origin]);
|
|
44
|
+
|
|
45
|
+
const onMessage = useCallback(
|
|
46
|
+
async (e: WebViewMessageEvent) => {
|
|
47
|
+
// Chỉ tin message từ đúng origin của mini app.
|
|
48
|
+
if (!isAllowedUrl(app, e.nativeEvent.url)) {
|
|
49
|
+
onLog('host', 'warn', 'bỏ qua message từ origin lạ:', e.nativeEvent.url);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
let msg: any;
|
|
53
|
+
try {
|
|
54
|
+
msg = JSON.parse(e.nativeEvent.data);
|
|
55
|
+
} catch {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (msg?.type === 'log' && Array.isArray(msg.items)) {
|
|
59
|
+
msg.items.forEach((i: any) =>
|
|
60
|
+
onLog(
|
|
61
|
+
tag,
|
|
62
|
+
i.level === 'warn' || i.level === 'error' ? i.level : 'log',
|
|
63
|
+
String(i.msg),
|
|
64
|
+
),
|
|
65
|
+
);
|
|
66
|
+
} else if (msg?.type === 'getSession') {
|
|
67
|
+
try {
|
|
68
|
+
const session = await getSession(app.id);
|
|
69
|
+
onLog('host', 'log', `gửi session cho ${app.id}:`, session);
|
|
70
|
+
ref.current?.injectJavaScript(
|
|
71
|
+
`window.host && window.host._deliver(${JSON.stringify(session)}); true;`,
|
|
72
|
+
);
|
|
73
|
+
} catch (err) {
|
|
74
|
+
onLog('host', 'error', `không lấy được session cho ${app.id}:`, String(err));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
[app, tag, getSession, onLog],
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
// Chặn điều hướng ra ngoài origin; link lạ mở bằng trình duyệt hệ thống.
|
|
82
|
+
const onShouldStart = useCallback(
|
|
83
|
+
(req: { url: string }) => {
|
|
84
|
+
if (isAllowedUrl(app, req.url)) {
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
onLog('host', 'warn', 'chặn điều hướng:', req.url);
|
|
88
|
+
if (/^https?:/.test(req.url)) {
|
|
89
|
+
Linking.openURL(req.url);
|
|
90
|
+
}
|
|
91
|
+
return false;
|
|
92
|
+
},
|
|
93
|
+
[app, onLog],
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
// Dùng lâu, thiếu RAM thì OS kill tiến trình web (màn trắng) → tải lại.
|
|
97
|
+
const reload = useCallback(() => {
|
|
98
|
+
onLog('host', 'warn', `tiến trình web của ${app.id} bị kill, tải lại`);
|
|
99
|
+
ref.current?.reload();
|
|
100
|
+
}, [app.id, onLog]);
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<View style={[styles.container, { paddingTop: insets.top }]}>
|
|
104
|
+
<Header title={app.name} onClose={onClose} closeLabel={closeLabel} />
|
|
105
|
+
<WebView<{}>
|
|
106
|
+
ref={ref}
|
|
107
|
+
source={source}
|
|
108
|
+
originWhitelist={whitelist}
|
|
109
|
+
injectedJavaScriptBeforeContentLoaded={BRIDGE_SCRIPT}
|
|
110
|
+
onMessage={onMessage}
|
|
111
|
+
onShouldStartLoadWithRequest={onShouldStart}
|
|
112
|
+
onContentProcessDidTerminate={reload}
|
|
113
|
+
onRenderProcessGone={reload}
|
|
114
|
+
onLoadStart={() => onLog('host', 'log', `mở ${app.id}: ${app.url}`)}
|
|
115
|
+
onError={e =>
|
|
116
|
+
onLog('host', 'error', 'WebView lỗi:', e.nativeEvent.description)
|
|
117
|
+
}
|
|
118
|
+
webviewDebuggingEnabled={__DEV__}
|
|
119
|
+
/>
|
|
120
|
+
</View>
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const styles = StyleSheet.create({
|
|
125
|
+
container: { flex: 1, backgroundColor: '#fff' },
|
|
126
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Component,
|
|
3
|
+
ComponentType,
|
|
4
|
+
lazy,
|
|
5
|
+
ReactNode,
|
|
6
|
+
Suspense,
|
|
7
|
+
useCallback,
|
|
8
|
+
useEffect,
|
|
9
|
+
useMemo,
|
|
10
|
+
useState,
|
|
11
|
+
} from 'react';
|
|
12
|
+
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
|
|
13
|
+
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
14
|
+
import { Header } from './Header';
|
|
15
|
+
import { consoleLog } from './log';
|
|
16
|
+
import type { GetSession, LogLevel, MiniAppProps, OnLog, Session } from './types';
|
|
17
|
+
|
|
18
|
+
export type RnMiniApp = { id: string; name: string };
|
|
19
|
+
|
|
20
|
+
export type RnMiniAppHostProps = {
|
|
21
|
+
app: RnMiniApp;
|
|
22
|
+
/**
|
|
23
|
+
* Tải module mini app, vd `() => import('wallet/App')` với remote khai báo
|
|
24
|
+
* trong rspack.config của host. Truyền hàm cố định (khai báo ngoài component).
|
|
25
|
+
*/
|
|
26
|
+
load: () => Promise<{ default: ComponentType<MiniAppProps> }>;
|
|
27
|
+
getSession: GetSession;
|
|
28
|
+
onLog?: OnLog;
|
|
29
|
+
onClose?: () => void;
|
|
30
|
+
closeLabel?: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Mini app chạy chung runtime với host: lỗi render của nó phải bị chặn ở đây,
|
|
34
|
+
// nếu không sẽ làm sập cả app cha.
|
|
35
|
+
class Boundary extends Component<
|
|
36
|
+
{ tag: string; onLog: OnLog; children: ReactNode },
|
|
37
|
+
{ error?: Error }
|
|
38
|
+
> {
|
|
39
|
+
state: { error?: Error } = {};
|
|
40
|
+
static getDerivedStateFromError(error: Error) {
|
|
41
|
+
return { error };
|
|
42
|
+
}
|
|
43
|
+
componentDidCatch(error: Error) {
|
|
44
|
+
this.props.onLog(this.props.tag, 'error', error.message);
|
|
45
|
+
}
|
|
46
|
+
render() {
|
|
47
|
+
return this.state.error ? (
|
|
48
|
+
<Text style={styles.error}>
|
|
49
|
+
Không tải được mini app: {this.state.error.message}
|
|
50
|
+
</Text>
|
|
51
|
+
) : (
|
|
52
|
+
this.props.children
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function RnMiniAppHost({
|
|
58
|
+
app,
|
|
59
|
+
load,
|
|
60
|
+
getSession,
|
|
61
|
+
onLog = consoleLog,
|
|
62
|
+
onClose,
|
|
63
|
+
closeLabel = 'Đóng',
|
|
64
|
+
}: RnMiniAppHostProps) {
|
|
65
|
+
const insets = useSafeAreaInsets();
|
|
66
|
+
const tag = `mini:${app.id}`;
|
|
67
|
+
// Bundle chỉ được tải khi mini app được mở lần đầu.
|
|
68
|
+
const Remote = useMemo(() => lazy(load), [load]);
|
|
69
|
+
const [session, setSession] = useState<Session | null>(null);
|
|
70
|
+
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
let alive = true;
|
|
73
|
+
Promise.resolve(getSession(app.id)).then(
|
|
74
|
+
s => {
|
|
75
|
+
if (!alive) return;
|
|
76
|
+
onLog('host', 'log', `gửi session cho ${app.id}:`, s);
|
|
77
|
+
setSession(s);
|
|
78
|
+
},
|
|
79
|
+
err => onLog('host', 'error', `không lấy được session cho ${app.id}:`, String(err)),
|
|
80
|
+
);
|
|
81
|
+
return () => {
|
|
82
|
+
alive = false;
|
|
83
|
+
};
|
|
84
|
+
}, [app.id, getSession, onLog]);
|
|
85
|
+
|
|
86
|
+
const miniLog = useCallback(
|
|
87
|
+
(level: LogLevel, ...args: unknown[]) => onLog(tag, level, ...args),
|
|
88
|
+
[tag, onLog],
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<View style={[styles.container, { paddingTop: insets.top }]}>
|
|
93
|
+
<Header title={app.name} onClose={onClose} closeLabel={closeLabel} />
|
|
94
|
+
<Boundary tag={tag} onLog={onLog}>
|
|
95
|
+
<Suspense fallback={<ActivityIndicator style={styles.loading} />}>
|
|
96
|
+
{session ? (
|
|
97
|
+
<Remote session={session} log={miniLog} />
|
|
98
|
+
) : (
|
|
99
|
+
<ActivityIndicator style={styles.loading} />
|
|
100
|
+
)}
|
|
101
|
+
</Suspense>
|
|
102
|
+
</Boundary>
|
|
103
|
+
</View>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const styles = StyleSheet.create({
|
|
108
|
+
container: { flex: 1, backgroundColor: '#fff' },
|
|
109
|
+
loading: { marginTop: 40 },
|
|
110
|
+
error: { margin: 16, color: '#dc2626' },
|
|
111
|
+
});
|
package/src/bridge.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Script chạy trong WebView trước khi trang load.
|
|
2
|
+
// - Gom console.log/warn/error + lỗi JS, gửi về host theo lô mỗi 300ms.
|
|
3
|
+
// - window.host.getSession(): Promise trả { token, expiresAt, user }.
|
|
4
|
+
// Không chứa token: host chỉ trả session sau khi kiểm tra origin.
|
|
5
|
+
export const BRIDGE_SCRIPT = `
|
|
6
|
+
(function () {
|
|
7
|
+
if (window.host) return;
|
|
8
|
+
var queue = [];
|
|
9
|
+
var waiting = [];
|
|
10
|
+
function send(m) { window.ReactNativeWebView.postMessage(JSON.stringify(m)); }
|
|
11
|
+
function fmt(a) {
|
|
12
|
+
if (typeof a === 'string') return a;
|
|
13
|
+
if (a instanceof Error) return a.stack || a.message;
|
|
14
|
+
try { return JSON.stringify(a); } catch (e) { return String(a); }
|
|
15
|
+
}
|
|
16
|
+
function push(level, args) {
|
|
17
|
+
if (queue.length >= 50) return; // chống spam làm nghẽn bridge
|
|
18
|
+
queue.push({ level: level, msg: [].map.call(args, fmt).join(' ').slice(0, 1000) });
|
|
19
|
+
}
|
|
20
|
+
['log', 'warn', 'error'].forEach(function (level) {
|
|
21
|
+
var orig = console[level];
|
|
22
|
+
console[level] = function () { orig.apply(console, arguments); push(level, arguments); };
|
|
23
|
+
});
|
|
24
|
+
window.addEventListener('error', function (e) {
|
|
25
|
+
push('error', [e.message + ' (' + e.filename + ':' + e.lineno + ')']);
|
|
26
|
+
});
|
|
27
|
+
window.addEventListener('unhandledrejection', function (e) {
|
|
28
|
+
push('error', ['Unhandled rejection:', e.reason]);
|
|
29
|
+
});
|
|
30
|
+
setInterval(function () {
|
|
31
|
+
if (queue.length) { send({ type: 'log', items: queue }); queue = []; }
|
|
32
|
+
}, 300);
|
|
33
|
+
window.host = {
|
|
34
|
+
getSession: function () {
|
|
35
|
+
return new Promise(function (resolve) {
|
|
36
|
+
waiting.push(resolve);
|
|
37
|
+
if (waiting.length === 1) send({ type: 'getSession' });
|
|
38
|
+
});
|
|
39
|
+
},
|
|
40
|
+
_deliver: function (session) {
|
|
41
|
+
var w = waiting; waiting = [];
|
|
42
|
+
w.forEach(function (resolve) { resolve(session); });
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
})();
|
|
46
|
+
true;
|
|
47
|
+
`;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { BRIDGE_SCRIPT } from './bridge';
|
|
2
|
+
export { consoleLog, redact } from './log';
|
|
3
|
+
export { isAllowedUrl, MiniAppWebView } from './MiniAppWebView';
|
|
4
|
+
export type { MiniAppWebViewProps, WebMiniApp } from './MiniAppWebView';
|
|
5
|
+
export { RnMiniAppHost } from './RnMiniAppHost';
|
|
6
|
+
export type { RnMiniApp, RnMiniAppHostProps } from './RnMiniAppHost';
|
|
7
|
+
export type { GetSession, LogLevel, MiniAppProps, OnLog, Session } from './types';
|
package/src/log.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { OnLog } from './types';
|
|
2
|
+
|
|
3
|
+
/** JSON.stringify nhưng che mọi trường `token`, kể cả lồng sâu. */
|
|
4
|
+
export const redact = (value: unknown) =>
|
|
5
|
+
JSON.stringify(value, (key, v) => (key === 'token' ? '***' : v));
|
|
6
|
+
|
|
7
|
+
/** Log mặc định: in ra console với prefix [tag], token bị che. */
|
|
8
|
+
export const consoleLog: OnLog = (tag, level, ...args) =>
|
|
9
|
+
console[level](
|
|
10
|
+
`[${tag}]`,
|
|
11
|
+
...args.map(a => (typeof a === 'string' ? a : redact(a))),
|
|
12
|
+
);
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type LogLevel = 'log' | 'warn' | 'error';
|
|
2
|
+
|
|
3
|
+
/** Dữ liệu host cấp cho mini app. `user` tuỳ app, tối thiểu có `id`. */
|
|
4
|
+
export type Session = {
|
|
5
|
+
token: string;
|
|
6
|
+
expiresAt: number;
|
|
7
|
+
user: { id: string } & Record<string, unknown>;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/** Host tự quyết lấy session thế nào (token giả, hoặc gọi backend đổi token). */
|
|
11
|
+
export type GetSession = (miniAppId: string) => Session | Promise<Session>;
|
|
12
|
+
|
|
13
|
+
/** Nơi host nhận log của chính nó (`tag = 'host'`) và của mini app (`tag = 'mini:<id>'`). */
|
|
14
|
+
export type OnLog = (tag: string, level: LogLevel, ...args: unknown[]) => void;
|
|
15
|
+
|
|
16
|
+
/** Props mà mini app React Native nhận từ host. */
|
|
17
|
+
export type MiniAppProps = {
|
|
18
|
+
session: Session;
|
|
19
|
+
log: (level: LogLevel, ...args: unknown[]) => void;
|
|
20
|
+
};
|