A unified, promise-based JavaScript bridge for bidirectional communication between web content and native mobile apps. window.jsbridge works identically on Android and iOS. Life's too short for platform if statements.
- Why jsbridge?
- Architecture
- For Web Developers
- For Native App Developers
- Project Structure
- Performance
- Changelog
- Support
- License
If you've built web content inside a native app, you know the drill. Android gives you @JavascriptInterface with string callbacks. iOS gives you WKScriptMessageHandler with a completely different API. You end up with code like this:
// The "before" world. We've all been here.
function getDeviceInfo() {
if (window.AndroidBridge) {
const cbName = 'onDeviceInfo_' + Date.now();
window[cbName] = function (json) {
const result = JSON.parse(json); // Android sends strings, naturally
delete window[cbName];
doSomethingWith(result);
};
window.AndroidBridge.getDeviceInfo(cbName);
} else if (window.webkit?.messageHandlers?.nativeBridge) {
window.onDeviceInfoResult = function (result) {
doSomethingWith(result); // iOS sends objects, because why be consistent
};
window.webkit.messageHandlers.nativeBridge.postMessage({
action: 'getDeviceInfo', callbackName: 'onDeviceInfoResult'
});
} else {
doSomethingWith({ platform: 'desktop' }); // shrug
}
}With jsbridge, the same thing is:
const info = await jsbridge.call('deviceInfo');That's it. One line. Both platforms. Promises. No callbacks polluting window. No platform sniffing.
Without a unified versioning scheme, backwards compatibility turns into archaeology:
// Real code from a real codebase. Names changed to protect the guilty.
if (platform === 'android' && semver.gte(appVersion, '4.2.0')) {
AndroidBridge.showToastV2(JSON.stringify({ message, style: 'custom' }));
} else if (platform === 'android' && semver.gte(appVersion, '3.0.0')) {
AndroidBridge.showToast(message);
} else if (platform === 'ios' && semver.gte(appVersion, '4.1.0')) {
webkit.messageHandlers.toast.postMessage({ message, style: 'custom' });
} else if (platform === 'ios') {
webkit.messageHandlers.nativeBridge.postMessage({ type: 'toast', message });
} else {
alert(message); // desktop fallback, the last refuge
}With jsbridge, schema versioning is a single integer:
if (jsbridge.schemaVersion >= 2) {
await jsbridge.call('showToast', { message, style: 'custom' });
} else {
await jsbridge.call('showToast', { message });
}No semver parsing, no platform branching, no archaeological expeditions into old release notes. Native silently ignores messages from newer schema versions. Your call times out, you fallback gracefully.
- One API, both platforms. Write bridge calls once. Ship everywhere.
- Promises, not callbacks.
async/awaitall the way down. No morewindow.callbackXyz_12345. - Schema versioning. One integer instead of platform x version matrices.
- Built-in commands. Navigation bars, system bars, safe area, haptics, storage, analytics. Ready to go.
- Easy to extend. Adding a new native command is ~50 lines. You never touch bridge infrastructure.
- Easy to test. Mock handler for desktop, debug logging, browser DevTools support.
graph TB
subgraph web [Web Layer]
WebApp["Your web app"]
API["window.jsbridge\n.call() / .on()"]
end
subgraph js [bridge.js — injected at document start]
PromiseMgmt["Promise management\nTimeouts / IDs"]
PlatformDetect["Platform auto-detection"]
end
subgraph native [Native Layer]
AndroidBridge["Android Bridge\n@JavascriptInterface\npostMessage / evaluateJavascript"]
iOSBridge["iOS Bridge\nWKScriptMessageHandler\npostMessage / evaluateJavaScript"]
end
subgraph commands [Command Registry — strategy pattern]
Cmds["DeviceInfo · Navigation · Toast\nHaptic · Storage · Insets · ..."]
end
subgraph output [Side Effects]
SafeArea["Safe Area CSS\n--bridge-inset-* variables"]
Lifecycle["Lifecycle Events\nfocused / defocused"]
Response["Promise Resolution\n{ id, data } or { id, error }"]
end
WebApp --> API
API --> PromiseMgmt
PromiseMgmt --> PlatformDetect
PlatformDetect --> AndroidBridge
PlatformDetect --> iOSBridge
AndroidBridge --> Cmds
iOSBridge --> Cmds
Cmds --> Response
Cmds --> SafeArea
Cmds --> Lifecycle
Response --> API
SafeArea --> WebApp
Lifecycle --> API
The key insight: bridge.js is the single source of truth. Both platforms inject the exact same JavaScript. Native just templates in the bridge name and schema version. Web developers see one global (window.jsbridge), native developers see their familiar APIs (@JavascriptInterface / WKScriptMessageHandler).
await jsbridge.ready();
const info = await jsbridge.call('deviceInfo');
console.log(info.platform, info.model);
jsbridge.on((msg) => {
const { action, content } = msg.data;
if (action === 'lifecycle' && content.event === 'focused') refreshData();
});That's the whole API. One method to call native (call), one to listen (on). Everything else is just different action strings.
| Method | Description |
|---|---|
jsbridge.ready() |
Returns a Promise that resolves when the bridge is initialized. Call this first. |
jsbridge.call(action, content?, opts?) |
Sends a request to native. Returns a Promise with the response. |
jsbridge.on(fn) |
Registers a handler for native-to-web messages. Supports multiple handlers. |
jsbridge.off(fn) |
Removes a previously registered handler. |
jsbridge.cancelAll() |
Rejects all pending promises and clears timeouts. Useful for navigation teardown. |
jsbridge.setDebug(bool) |
Enables verbose console logging. |
jsbridge.setMockHandler(fn) |
Registers a mock for desktop browser testing. |
jsbridge.platform |
'android' | 'ios' | 'desktop' (read-only) |
jsbridge.schemaVersion |
Integer version set by native (read-only) |
jsbridge.getStats() |
Returns { pendingRequests, schemaVersion, platform, handlers, debugEnabled } |
All of these work on both platforms with zero native setup (just use DefaultCommands.all()):
| Category | Actions | What it does |
|---|---|---|
| Navigation | topNavigation, bottomNavigation, systemBars, systemBarsInfo |
Show/hide the top toolbar, bottom tab bar, status bar, system navigation. Query individual bar dimensions and visibility. Web controls native chrome. |
| UI | showToast, showAlert, haptic, copyToClipboard |
Native toast, alert dialogs, haptic feedback, clipboard. Things web can't do well on its own. |
| Device | deviceInfo, networkState, openSettings, requestPermissions |
Platform, OS version, model, connectivity, native settings screen, runtime permissions. |
| Storage | saveSecureData, loadSecureData, removeSecureData |
Encrypted storage backed by Keychain (iOS) and EncryptedSharedPreferences (Android). |
| Layout | insets, systemBarsInfo + CSS custom properties |
Safe area insets (dp/pt), keyboard height, individual bar dimensions and visibility. Pushed automatically as CSS variables. |
| Lifecycle | lifecycle events via on() |
Know when your screen is actually visible (not buried under modals or other tabs). |
| Analytics | trackEvent, trackScreen |
Fire-and-forget. Don't even await these. |
| Navigation | navigation |
Load URLs in-app or externally, go back. |
That's a lot of native capability accessible from a single await jsbridge.call(...).
await jsbridge.call('actionName', { key: 'value' }, { timeout: 5000 });call() also accepts the full message object for backward compatibility:
await jsbridge.call({
data: {
action: 'actionName', // what to do
content: { key: 'value' } // optional payload
}
}, { timeout: 5000 }); // optional timeout (default 30s)const info = await jsbridge.call('deviceInfo');
// → { platform, osVersion, model, appVersion, ... }
const net = await jsbridge.call('networkState');
// → { connected: true, type: 'wifi' }
await jsbridge.call('openSettings');
const insets = await jsbridge.call('insets');
// → { statusBar: { height, visible }, systemNavigation: {...}, keyboard: {...},
// safeArea: { top, right, bottom, left } }
// All values in dp (Android) / pt (iOS)
// safeArea.top = statusBar + topNavigation (when visible)
// safeArea.bottom = systemNavigation + bottomNavigation (when visible)
const bars = await jsbridge.call('systemBarsInfo');
// → { statusBar: { height, isVisible }, topNavigation: { height, isVisible },
// bottomNavigation: { height, isVisible }, systemNavigation: { height, isVisible } }await jsbridge.call('showToast', { message: 'Hey!', duration: 'short' });
await jsbridge.call('showAlert', { title: 'Hi', message: 'Hello', buttons: ['OK', 'Cancel'] });
await jsbridge.call('haptic', { vibrate: true });
await jsbridge.call('copyToClipboard', { text: 'copied!' });// Top navigation bar
await jsbridge.call('topNavigation', { isVisible: true, title: 'Home', showUpArrow: false });
// Bottom navigation bar
await jsbridge.call('bottomNavigation', { isVisible: false });
// System bars (iOS handles status bar; system navigation is Android-only)
await jsbridge.call('systemBars', { showStatusBar: false, showSystemNavigation: false });
// URL navigation
await jsbridge.call('navigation', { url: 'https://example.com', external: true });
await jsbridge.call('navigation', { goBack: true });await jsbridge.call('saveSecureData', { key: 'token', value: 'abc123' });
const { value } = await jsbridge.call('loadSecureData', { key: 'token' });
await jsbridge.call('removeSecureData', { key: 'token' });Backed by Keychain (iOS) and EncryptedSharedPreferences (Android).
Don't await these. No response needed, zero latency:
jsbridge.call('trackEvent', { event: 'button_click', params: { screen: 'home' } });
jsbridge.call('trackScreen', { screenName: 'Home' });Modern mobile apps use edge-to-edge layouts. Your web content extends behind status bars and navigation bars. Great for immersive experiences like image galleries or video players. Terrible for buttons your users need to actually tap.
jsbridge solves this with native-driven CSS custom properties. Native automatically pushes inset values whenever bars change, the device rotates, or the keyboard appears. You don't call anything. Just use CSS:
body {
padding-top: var(--bridge-inset-top, env(safe-area-inset-top, 0px));
padding-bottom: var(--bridge-inset-bottom, env(safe-area-inset-bottom, 0px));
}The triple fallback (bridge variable → env() → 0px) means this works everywhere: in the app, in a regular browser, on desktop.
Available CSS custom properties:
| Property | What it represents |
|---|---|
--bridge-inset-top |
Combined top inset (status bar + top nav when relevant) |
--bridge-inset-bottom |
Combined bottom inset (bottom nav + system nav when relevant) |
--bridge-inset-left |
Left inset (landscape, foldables) |
--bridge-inset-right |
Right inset (landscape, foldables) |
--bridge-status-bar |
Status bar height alone |
--bridge-top-nav |
Top navigation bar height alone |
--bridge-bottom-nav |
Bottom tab bar height alone |
--bridge-system-nav |
System navigation bar height alone (Android) |
Example: immersive gallery with safe interactive elements
// Go full-screen: hide all native chrome
await jsbridge.call('topNavigation', { isVisible: false });
await jsbridge.call('bottomNavigation', { isVisible: false });
await jsbridge.call('systemBars', { showStatusBar: false, showSystemNavigation: false });.gallery {
/* Content goes edge-to-edge -- behind status bar, behind system nav */
position: fixed;
inset: 0;
}
.gallery-close-button {
/* But the close button stays in the safe area so users can actually tap it */
position: fixed;
top: calc(12px + var(--bridge-inset-top, env(safe-area-inset-top, 0px)));
right: 12px;
}
.gallery-controls {
/* Bottom controls respect the system navigation area */
position: fixed;
bottom: calc(12px + var(--bridge-inset-bottom, env(safe-area-inset-bottom, 0px)));
left: 0;
right: 0;
}Automatic safe padding based on bar visibility. When you toggle the top or bottom navigation, native automatically recalculates and pushes updated CSS values. Hide the top bar? --bridge-inset-top updates to include the status bar height so your content doesn't slip underneath it. Show it again? Inset goes back to 0 because the native bar already covers the status bar. You don't manage any of this. It just works.
For JavaScript layout calculations (e.g. canvas sizing), query on demand:
const insets = await jsbridge.call('insets');
// → { safeArea: { top: 86.13, bottom: 14.93, ... }, statusBar: { height: 30.13, visible: true }, ... }
// All values in dp (Android) / pt (iOS)
// For individual bar dimensions and visibility:
const bars = await jsbridge.call('systemBarsInfo');
// → { statusBar: { height: 30.13, isVisible: true }, topNavigation: { height: 56.0, isVisible: true }, ... }Here's a fun problem: your WebView is happily displaying content, but the user opens a bottom sheet, a modal dialog, switches to another tab in a ViewPager, or navigates to a different screen that covers your WebView. The web content has no idea. No visibilitychange event, no blur, nothing. As far as your JavaScript knows, the page is still in the foreground.
jsbridge fixes this. Native sends lifecycle events whenever your screen gains or loses focus:
jsbridge.on((msg) => {
const { action, content } = msg.data;
if (action === 'lifecycle') {
if (content.event === 'focused') {
refreshData(); // screen is visible again -- refresh stale data
resumeAnimations();
}
if (content.event === 'defocused') {
pauseAnimations(); // save battery, stop polling
}
}
});This covers all the tricky cases:
- Modal/dialog/bottom sheet presented over the WebView
- User switches tabs in a native tab bar or ViewPager
- App goes to background and comes back
- Another ViewController/Fragment pushed on top of the WebView
On focused, native also re-pushes safe area CSS, so your layout is always correct after returning from another screen.
Simple integer, auto-attached to every message. Native silently ignores messages from newer schema versions. Your call will timeout, and you can fallback:
if (jsbridge.schemaVersion >= 2) {
await jsbridge.call('fancyNewThing');
} else {
oldApproach();
}Bump when: breaking format changes, removing commands, incompatible behavior. Don't bump when: adding commands, adding optional fields, fixing bugs.
try {
const info = await jsbridge.call('deviceInfo', null, { timeout: 5000 });
} catch (e) {
if (e.message.includes('timeout')) console.warn('Native not responding');
else console.error(e.code, e.message);
}Errors have .code (e.g. UNKNOWN_ACTION, INVALID_PARAMETER) and .message.
When no native bridge is detected, jsbridge.platform returns 'desktop'. Register a mock to develop and test without a device:
jsbridge.setMockHandler((msg) => {
if (msg.data.action === 'deviceInfo') return { platform: 'desktop', model: 'Chrome' };
if (msg.data.action === 'networkState') return { connected: true, type: 'wifi' };
return {};
});The index.html demo page does this automatically. Open it in any browser and everything works.
- Connect your Android device via USB (enable USB debugging)
- Open
chrome://inspectin Chrome - Find your WebView under Remote Target and click Inspect
- In the Console tab, you have full access:
// Try it live
await jsbridge.call('deviceInfo')
jsbridge.getStats()
jsbridge.setDebug(true) // watch all messages fly by in the console- Enable Web Inspector on your device: Settings → Safari → Advanced → Web Inspector
- Connect via USB, open Safari on your Mac
- Develop menu → your device → your WebView
- Same console access as Chrome:
await jsbridge.call('showToast', { message: 'Hello from Safari!' })
jsbridge.platform // → 'ios'Turn on verbose logging anywhere to see every message going back and forth:
jsbridge.setDebug(true);
// Now every call(), response, and native message logs to the console
// [jsbridge] Calling native: { id: "msg_...", data: { action: "deviceInfo" } }
// [jsbridge] Received response: { id: "msg_...", data: { platform: "Android", ... } }jsbridge.getStats() gives you a snapshot of the bridge state. Handy for debugging stuck promises:
jsbridge.getStats()
// → { pendingRequests: 0, schemaVersion: 1, platform: 'android', handlers: 1, debugEnabled: true }interface CallOptions { timeout?: number; version?: number; }
interface Bridge {
schemaVersion: number;
platform: 'android' | 'ios' | 'desktop';
ready(): Promise<void>;
call<T = unknown>(action: string, content?: unknown, opts?: CallOptions): Promise<T>;
call<T = unknown>(msg: { data: { action: string; content?: unknown } }, opts?: CallOptions): Promise<T>;
on(handler: (msg: { data: { action: string; content?: unknown } }) => void): void;
off(handler: Function): void;
cancelAll(): void;
setDebug(enabled: boolean): void;
setMockHandler(handler: Function | null): void;
getStats(): { pendingRequests: number; schemaVersion: number; platform: string; handlers: number; debugEnabled: boolean };
}
declare global { interface Window { jsbridge: Bridge } }- Always
await jsbridge.ready()before anything else - Set timeouts on calls:
{ timeout: 5000 }. 30s default is generous - Don't await analytics. Fire-and-forget is faster
- Version-gate new features:
if (jsbridge.schemaVersion >= 2) { ... } - Cache device info. It doesn't change mid-session
- Batch with
Promise.all()for parallel operations - Register
on()once during init, route byaction
| Action | iOS | Android |
|---|---|---|
deviceInfo |
✅ | ✅ |
networkState |
✅ | ✅ |
openSettings |
✅ | ✅ |
insets |
✅ | ✅ |
systemBarsInfo |
✅ | ✅ |
showToast |
✅ | ✅ |
showAlert |
✅ | ✅ |
topNavigation |
✅ | ✅ |
bottomNavigation |
✅ | ✅ |
systemBars |
✅ | ✅ |
haptic |
✅ | ✅ |
navigation |
✅ | ✅ |
copyToClipboard |
✅ | ✅ |
lifecycleEvents |
✅ | ✅ |
saveSecureData |
✅ | ✅ |
loadSecureData |
✅ | ✅ |
removeSecureData |
✅ | ✅ |
requestPermissions |
✅ | ✅ |
trackEvent |
✅ | ✅ |
trackScreen |
✅ | ✅ |
The bridge is a thin decorator around each platform's native WebView messaging:
- Android:
@JavascriptInterfaceon apostMessage(String)method +evaluateJavascript()for responses - iOS:
WKScriptMessageHandler+evaluateJavaScript()for responses
Both platforms inject the same JavaScript (bridge.js) into the WebView at document start. The JS auto-detects the platform and routes messages accordingly. Web developers see one API; you see your native APIs.
Add the jsbridge library module to your project:
// settings.gradle.kts
include(":jsbridge")
// app/build.gradle.kts
dependencies {
implementation(project(":jsbridge"))
}Wire it up with explicit command configuration:
// All default commands
val bridge = JavaScriptBridge.inject(
webView = webView,
commands = DefaultCommands.all()
)
// Or pick only the commands you need
val bridge = JavaScriptBridge.inject(
webView = webView,
commands = listOf(
DeviceInfoCommand(),
ShowToastCommand(),
HapticCommand(),
)
)
// Send events to web
bridge.sendToWeb(action = "lifecycle", content = mapOf("event" to "focused"))Add the JSBridge Swift Package (local dependency):
// In your Package.swift or Xcode project
.package(path: "../JSBridge")Wire it up with explicit command configuration:
import JSBridge
// All default commands
let bridge = JavaScriptBridge(
webView: webView,
viewController: self,
commands: DefaultCommands.all()
)
// Or pick only the commands you need
let bridge = JavaScriptBridge(
webView: webView,
viewController: self,
commands: [
DeviceInfoCommand(),
ShowToastCommand(),
HapticCommand(),
]
)
// Send events to web
bridge.sendToWeb(action: "lifecycle", content: ["event": "focused"])Web content has no way to know when it's been covered by a modal, buried in a tab, or returned to after a navigation stack change. It's your job to tell it. Luckily, it's one line:
// Android -- in your Activity or Fragment
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
bridge.sendToWeb("lifecycle", mapOf("event" to if (hasFocus) "focused" else "defocused"))
if (hasFocus) SafeAreaService.pushTobridge(bridge) // re-push safe area on return
}// iOS -- in your ViewController (or use the WindowFocusObserver protocol)
func onWindowFocusChanged(hasFocus: Bool) {
if hasFocus {
bridge.sendToWeb(action: "lifecycle", content: ["event": "focused"])
SafeAreaService.shared.pushToBridge(bridge)
} else {
bridge.sendToWeb(action: "lifecycle", content: ["event": "defocused"])
}
}The iOS sample app includes a WindowFocusObserver protocol that handles all the edge cases (modal presentation, tab switching, background/foreground, ViewPager) with a lightweight polling approach.
This is the whole point of the architecture. Three steps, ~50 lines total:
1. Create the handler:
// Android
class MyCommand : BridgeCommand {
override val action = "myAction"
override suspend fun handle(content: Any?): Any? {
val param = BridgeParsingUtils.parseString(content, "param")
return JSONObject().apply { put("result", "done: $param") }
}
}// iOS
class MyCommand: BridgeCommand {
let action = "myAction"
func handle(content: [String: Any]?) async throws -> [String: Any]? {
let param = content?["param"] as? String ?? ""
return ["result": "done: \(param)"]
}
}2. Register it when creating the bridge:
// Android
val bridge = JavaScriptBridge.inject(
webView = webView,
commands = DefaultCommands.all() + MyCommand()
)// iOS
let bridge = JavaScriptBridge(
webView: webView, viewController: self,
commands: DefaultCommands.all() + [MyCommand()]
)3. Call from web:
const result = await jsbridge.call('myAction', { param: 'hello' });You didn't touch the bridge infrastructure, the JS layer, or any other command. That's the beauty of the command pattern.
Instead of making web poll for insets, native proactively pushes CSS custom properties:
// Android: called automatically on page load, bar changes, rotation, keyboard
bridge.updateSafeAreaCSS(
insetTop = statusBarHeight + topNavHeight,
insetBottom = bottomNavHeight + systemNavHeight,
statusBarHeight = statusBarHeight,
topNavHeight = topNavHeight,
bottomNavHeight = bottomNavHeight,
systemNavHeight = systemNavHeight
)The SafeAreaService handles this automatically when using DefaultCommands. Top/bottom navigation commands recalculate and push updated CSS after every visibility change. The BridgeWebViewClient (Android) pushes on onPageFinished, and both platforms re-push when the window regains focus.
This is strictly better than injecting padding-top: Xpx !important because web controls where and how to use the values.
You can register multiple bridges with different names on the same WebView, each handling different actions:
// Android
val mainBridge = JavaScriptBridge.inject(
webView, bridgeName = "jsbridge",
commands = listOf(DeviceInfoCommand(), ShowToastCommand())
)
val analyticsBridge = JavaScriptBridge.inject(
webView, bridgeName = "analytics",
commands = listOf(TrackEventCommand(), TrackScreenCommand())
)// iOS
let mainBridge = JavaScriptBridge(
webView: webView, viewController: self, bridgeName: "jsbridge",
commands: [DeviceInfoCommand(), ShowToastCommand(), HapticCommand()]
)
let analyticsBridge = JavaScriptBridge(
webView: webView, viewController: self, bridgeName: "analytics",
commands: [TrackEventCommand(), TrackScreenCommand()]
)// Web -- each bridge is independent
await jsbridge.call('deviceInfo');
await analytics.call('trackEvent', { event: 'click' });// Android
val bridge = JavaScriptBridge.inject(
webView = webView,
commands = DefaultCommands.all(),
bridgeName = "myApp"
)// iOS
let bridge = JavaScriptBridge(
webView: webView, viewController: self,
bridgeName: "myApp",
commands: DefaultCommands.all()
)One parameter. Everything else (the JS global, the callbacks, the message handler name) updates automatically.
Standardized across both platforms: { id, data?, error? }. If error is present, it's a failure. If data is present (or both absent), it's a success. Return null from a command handler for fire-and-forget (no response sent).
{
"id": "msg_1234567890_1_abc123xyz",
"version": 1,
"data": {
"action": "showToast",
"content": { "message": "Hello!" }
}
}Version checking: if version > SCHEMA_VERSION, silently ignore. Web will timeout and fallback.
- The
window.jsbridgeobject isObject.freeze()'d and non-writable - Android: only
@JavascriptInterface-annotated methods are exposed - iOS: WKScriptMessageHandler with named handler registration
- All commands validate their input
- Threading: Android
@JavascriptInterfaceruns on a background thread. All WebView operations are dispatched to main
jsbridge/
├── bridge.js # Unified JS (single source of truth)
├── index.html # Demo page (symlinked into both sample apps)
├── android/
│ ├── jsbridge/ # Library module (net.kibotu.jsbridge)
│ │ ├── build.gradle.kts
│ │ └── src/main/java/net/kibotu/jsbridge/
│ │ ├── JavaScriptBridge.kt
│ │ ├── DefaultCommands.kt
│ │ ├── SafeAreaService.kt
│ │ ├── commands/ # One file per action
│ │ └── decorators/ # WebViewClient/ChromeClient wrappers
│ └── sample/ # Sample app (depends on :jsbridge)
│ └── src/main/java/net/kibotu/bridgesample/
├── ios/
│ ├── JSBridge/ # Swift Package
│ │ ├── Package.swift
│ │ └── Sources/JSBridge/
│ │ ├── JavaScriptBridge.swift
│ │ ├── DefaultCommands.swift
│ │ ├── SafeAreaService.swift
│ │ ├── Commands/ # One file per action
│ │ └── Resources/bridge.js
│ └── BridgeSample/ # Sample app (depends on JSBridge)
│ └── Views/
└── CHANGELOG.md
Android: Open android/ in Android Studio, hit Run on the :sample module.
iOS: Open ios/BridgeSample.xcodeproj in Xcode, add JSBridge as a local SPM dependency, Cmd+R.
Both apps load the same index.html demo page with all bridge features.
| Operation | Latency | Pattern |
|---|---|---|
| Fire-and-forget (analytics, haptic) | < 1ms | No await |
| Simple queries (deviceInfo) | 5-15ms | await |
| UI operations (toast, alert) | 10-30ms | await |
| Secure storage | 20-50ms | await |
Batch with Promise.all(). Cache what doesn't change. Don't await what doesn't matter.
Library extraction. Breaking changes:
- Android: Bridge code extracted into
:jsbridgelibrary module (net.kibotu.jsbridge). RemovedBridgeMessageHandler/DefaultBridgeMessageHandlerindirection. Commands are now passed directly toJavaScriptBridge.inject(). - iOS: Bridge code extracted into
JSBridgeSwift Package. Commands are now passed toJavaScriptBridge(commands:)init. No longer auto-registered. - Both platforms: Commands are opt-in via explicit configuration. Use
DefaultCommands.all()for all defaults, or pick individual commands. - Multiple bridges: A single WebView can now host multiple named bridges (e.g.
jsbridge+analytics) with independent command sets. - JS loading: Bridge JavaScript is loaded from bundled resources (
res/raw/bridge.json Android, SPM resource on iOS) instead of inline strings. - Directory structure:
android-sample/→android/,ios-sample/→ios/
call()now accepts bothcall(action, content?, opts?)shorthand andcall(msg, opts?)object form- Added
cancelAll()to reject all pending promises and clean up timeouts - Fixed Android error response asymmetry. Command errors now correctly reject promises (aligned with iOS)
- Added
trackEventandtrackScreencommands on Android - Added
requestPermissionscommand on iOS - Added iOS
BridgeParsingUtilsdictionary extensions for consistent parameter parsing - Migrated iOS
BridgeCommandprotocol from completion handlers toasync/await
Unified bridge release. Breaking changes:
- Bridge global renamed from
window.bridgetowindow.jsbridge(configurable) - Response format standardized to
{ id, data?, error? }(iOSsuccessfield removed) on()now supports multiple handlers (useoff()to remove)- Unified callback names across platforms
New: bridge.js single source of truth, platform property, off(), setMockHandler(), getStats(), insets action, native-driven safe area CSS injection, desktop mock mode.
Initial release. call(), on(), ready(), setDebug(). Android and iOS samples. Schema versioning. Command pattern.
If jsbridge saved you a few hours (or a few if statements), consider buying me a coffee.
See LICENSE file.