Skip to content

Commit 2abbde9

Browse files
authored
feat: add composable bridge middleware (#117)
1 parent 260df39 commit 2abbde9

13 files changed

Lines changed: 814 additions & 152 deletions

File tree

.changeset/quiet-dingos-guard.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@webview-bridge/react-native": minor
3+
---
4+
5+
Add chainable `bridge(...).use(middleware)` support for composing Web-to-Native
6+
request authentication, preprocessing, short-circuiting, and response logic.

docs/reference/react-native/create-webview.md

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,38 @@ The `createWebView` is used to create a WebView with an interface that enables c
88
| `bridge` | Object | true | X | Represents the bridge between React Native and the WebView. |
99
| `debug` | boolean | false | false | Outputs console.log from the web in React Native. |
1010
| `responseTimeout`| number | false | 2000 | Timeout duration when executing web methods. |
11-
| `fallback` | (method: keyof T) => void | false | X |Callback function called when a method from the bridge is not found. |
11+
| `fallback` | (method: keyof T) => void | false | X |Callback function called when a method from the bridge is not found. |
12+
13+
## Bridge middleware
14+
15+
`bridge(...).use(...)` registers instance-scoped middleware for Web-to-Native
16+
method calls.
17+
18+
```tsx
19+
import {
20+
bridge,
21+
type BridgeMiddleware,
22+
} from "@webview-bridge/react-native";
23+
24+
const logger: BridgeMiddleware = async ({ method }, next) => {
25+
console.log(`Calling ${method}`);
26+
const result = await next();
27+
console.log(`Called ${method}`);
28+
return result;
29+
};
30+
31+
const appBridge = bridge({
32+
async getMessage() {
33+
return "Hello, I'm native";
34+
},
35+
}).use(logger);
36+
```
37+
38+
Middleware receives `{ url, method, args }` and `next()`. It may update `args`,
39+
attach typed context fields to the shared request, short-circuit without calling
40+
`next()`, or transform the returned value.
41+
`use()` returns the same bridge store for chaining; call `next()` at most once.
42+
43+
`url` is the top-level URL reported by `react-native-webview`, not a verified
44+
caller origin. Middleware applies only to bridge method calls and does not
45+
intercept navigation or network requests.
Lines changed: 118 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,122 @@
1-
import React from "react";
2-
import { SafeAreaView } from "react-native";
1+
import React from 'react';
2+
import { SafeAreaView, StyleSheet } from 'react-native';
33
import {
44
createWebView,
5+
type BridgeMiddleware,
6+
type BridgeStore,
57
type BridgeWebView,
68
bridge,
7-
} from "@webview-bridge/react-native";
8-
import InAppBrowser from "react-native-inappbrowser-reborn";
9+
} from '@webview-bridge/react-native';
10+
import InAppBrowser from 'react-native-inappbrowser-reborn';
11+
import 'react-native-url-polyfill/auto';
912

10-
export const appBridge = bridge({
13+
const WEB_APP_URL = new URL('http://localhost:5173');
14+
15+
const getUrlOrigin = (url: string) => new URL(url).origin;
16+
17+
const observeBridgeRequests = (): BridgeMiddleware =>
18+
async ({ method, url }, next) => {
19+
const startedAt = Date.now();
20+
let origin = 'unknown';
21+
try {
22+
if (url) {
23+
origin = getUrlOrigin(url);
24+
}
25+
} catch {
26+
origin = 'invalid URL';
27+
}
28+
29+
console.log(`[bridge] -> ${method} (${origin})`);
30+
try {
31+
const result = await next();
32+
console.log(`[bridge] <- ${method} (${Date.now() - startedAt}ms)`);
33+
return result;
34+
} catch (error) {
35+
console.error(
36+
`[bridge] !! ${method} (${Date.now() - startedAt}ms)`,
37+
error,
38+
);
39+
throw error;
40+
}
41+
};
42+
43+
const allowWebAppOrigin = (webAppUrl: URL): BridgeMiddleware => {
44+
const allowedOrigin = webAppUrl.origin;
45+
46+
return async ({ url }, next) => {
47+
let requestOrigin: string;
48+
try {
49+
if (!url) {
50+
throw new Error('Missing WebView URL');
51+
}
52+
requestOrigin = getUrlOrigin(url);
53+
} catch {
54+
throw new Error('Bridge request has an invalid WebView URL');
55+
}
56+
57+
if (requestOrigin !== allowedOrigin) {
58+
throw new Error(`Bridge request denied for origin: ${requestOrigin}`);
59+
}
60+
return next();
61+
};
62+
};
63+
64+
const normalizeExternalUrl = (): BridgeMiddleware =>
65+
async (request, next) => {
66+
if (request.method !== 'openInAppBrowser') {
67+
return next();
68+
}
69+
70+
const [input] = request.args;
71+
if (typeof input !== 'string' || input.trim().length === 0) {
72+
throw new TypeError('A non-empty URL is required');
73+
}
74+
75+
const trimmedUrl = input.trim();
76+
let externalUrl: URL;
77+
try {
78+
externalUrl = new URL(trimmedUrl);
79+
} catch {
80+
externalUrl = new URL(`https://${trimmedUrl}`);
81+
}
82+
83+
if (externalUrl.protocol !== 'https:') {
84+
throw new Error('Only HTTPS links can be opened');
85+
}
86+
87+
request.args = [externalUrl.toString()];
88+
return next();
89+
};
90+
91+
const configuredAppBridge = bridge({
1192
async getMessage() {
1293
return "I'm from native" as const;
1394
},
1495
async openInAppBrowser(url: string) {
15-
if (await InAppBrowser.isAvailable()) {
16-
await InAppBrowser.open(url);
96+
if (!(await InAppBrowser.isAvailable())) {
97+
return {
98+
openedUrl: url,
99+
status: 'unavailable' as const,
100+
};
17101
}
102+
103+
const result = await InAppBrowser.open(url);
104+
return {
105+
openedUrl: url,
106+
status: result.type,
107+
};
18108
},
19109
async throwError() {
20110
throw new Error('🚧 This error is from native side!!');
21111
},
22-
});
112+
})
113+
.use(observeBridgeRequests())
114+
.use(allowWebAppOrigin(WEB_APP_URL))
115+
.use(normalizeExternalUrl());
23116

117+
export const appBridge: BridgeStore<
118+
ReturnType<typeof configuredAppBridge.getState>
119+
> = configuredAppBridge;
24120

25121
export const { WebView } = createWebView({
26122
bridge: appBridge,
@@ -34,16 +130,27 @@ function App(): JSX.Element {
34130
const webviewRef = React.useRef<BridgeWebView>(null);
35131

36132
return (
37-
<SafeAreaView style={{ height: "100%" }}>
133+
<SafeAreaView style={styles.container}>
38134
<WebView
39135
ref={webviewRef}
40136
source={{
41-
uri: "http://localhost:5173",
137+
uri: WEB_APP_URL.href,
42138
}}
43-
style={{ height: "100%", flex: 1, width: "100%" }}
139+
style={styles.webView}
44140
/>
45141
</SafeAreaView>
46142
);
47143
}
48144

145+
const styles = StyleSheet.create({
146+
container: {
147+
height: '100%',
148+
},
149+
webView: {
150+
flex: 1,
151+
width: '100%',
152+
height: '100%',
153+
},
154+
});
155+
49156
export default App;

example/native-method/react-native/README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
11
This is a new [**React Native**](https://reactnative.dev) project, bootstrapped using [`@react-native-community/cli`](https://github.com/react-native-community/cli).
22

3+
## Bridge middleware example
4+
5+
`App.tsx` chains request logging, WebView URL authorization, and external URL
6+
normalization with `bridge(...).use(...)`. The paired `../react` app exercises
7+
the chain before opening a real native InAppBrowser.
8+
9+
From the repository root, run the middleware integration tests with:
10+
11+
```bash
12+
pnpm --filter @webview-bridge-example-native-method/react-native test --runInBand
13+
```
14+
15+
In the running example, a scheme-less URL is normalized to HTTPS, while the
16+
**Try blocked scheme** action is rejected before native browser code runs.
17+
318
# Getting Started
419

520
>**Note**: Make sure you have completed the [React Native - Environment Setup](https://reactnative.dev/docs/environment-setup) instructions till "Creating a new application" step, before proceeding.

0 commit comments

Comments
 (0)