A small bridge for Swift WKWebView apps that render TypeScript or React UIs and coordinate native SwiftUI state, menus, and popovers through message passing.
The bridge uses one JSON envelope in both directions:
{ "type": "eventName", "payload": {} }Swift receives messages through a WKScriptMessageHandler. TypeScript receives messages through an injected global dispatch object.
Add this repository to your app with Swift Package Manager and import the library:
import SwiftWebBridgenpm install swift-web-bridgeThe package exposes a core TypeScript entry and a React subpath:
import { createBridge } from "swift-web-bridge";
import { useNativeState } from "swift-web-bridge/react";import SwiftUI
import WebKit
import SwiftWebBridge
struct MenuPresentedPayload: BridgeEventPayload {
static let eventType = "menuPresented"
let presented: Bool
}
@State private var bridge = WebViewBridge()
@State private var selectedIndex = 0
let configuration = WKWebViewConfiguration()
bridge.install(in: configuration)
let webView = WKWebView(frame: .zero, configuration: configuration)
bridge.attach(to: webView)SwiftUI views can subscribe to web events and sync state with React:
content
.bridgeSubscribe(MenuPresentedPayload.self, bridge) { payload in
isPresented = payload.presented
}
.syncedReactState("selectedIndex", $selectedIndex, bridge)Swift can send typed events back to the web view:
struct ConfirmSelectionPayload: BridgeEventPayload {
static let eventType = "confirmSelection"
let index: Int
}
bridge.send(ConfirmSelectionPayload(index: selectedIndex))import {
createBridge,
type EmptyPayload,
type NativeStateEventMap,
} from "swift-web-bridge";
type NativeState = {
selectedIndex: number;
};
type Events = NativeStateEventMap<NativeState> & {
menuPresented: { presented: boolean };
confirmSelection: { index: number };
submit: EmptyPayload;
};
export const bridge = createBridge<Events>();
bridge.inject();
bridge.send("menuPresented", { presented: true });
bridge.on("confirmSelection", ({ index }) => {
console.log(index);
});React state sync lives in the React subpath:
import { useNativeState } from "swift-web-bridge/react";
import { bridge, type NativeState } from "./bridge";
const [selectedIndex, setSelectedIndex] =
useNativeState<NativeState, "selectedIndex">(
"selectedIndex",
0,
{ bridge }
);The Swift and TypeScript packages use matching defaults:
- WebKit message handler:
swiftWebBridge - JavaScript global:
window.__swiftWebBridge
Both are configurable through WebViewBridgeConfiguration on Swift and createBridge(...) or bridge.inject(...) on TypeScript.
public final class WebViewBridge: NSObject, WKScriptMessageHandlerThe main Swift bridge object. Keep it alive for as long as the WKWebView should communicate with Swift, usually with @State or another owner retained by your view/controller.
public init(configuration: WebViewBridgeConfiguration = .init())Creates a bridge with default or custom message-handler/global names.
public let configuration: WebViewBridgeConfiguration
public weak var webView: WKWebView?configuration stores the bridge names used by both sides. webView is weak; call attach(to:) after creating the WKWebView.
public func install(in webViewConfiguration: WKWebViewConfiguration)
public func install(in userContentController: WKUserContentController)Registers the bridge as a WKScriptMessageHandler. Call this before creating the WKWebView that uses the configuration.
public func uninstall(from userContentController: WKUserContentController)Removes the script message handler. Use this if you manage the WKUserContentController lifecycle manually.
public func attach(to webView: WKWebView)Stores the web view used by send(...) for Swift-to-TypeScript messages.
@discardableResult
public func on<P: BridgeEventPayload>(
_ payloadType: P.Type = P.self,
handler: @escaping (P) -> Void
) -> BridgeSubscriptionSubscribes to a typed payload whose event name comes from P.eventType.
@discardableResult
public func on<P: Decodable>(
_ type: String,
payload: P.Type = P.self,
handler: @escaping (P) -> Void
) -> BridgeSubscriptionSubscribes to an explicit event name and decodes the envelope payload as P.
@discardableResult
public func receive(_ text: String) -> BoolFeeds raw JSON into the bridge. This is mostly useful for tests and non-WebKit adapters. Returns true when at least one handler was found for the message type.
public func send<P: BridgeEventPayload>(_ payload: P)
public func send<P: Encodable>(_ type: String, payload: P)Encodes { type, payload } and dispatches it into the web view through the configured JavaScript global.
public struct WebViewBridgeConfiguration: Equatable, Sendable {
public static let defaultMessageHandlerName = "swiftWebBridge"
public static let defaultJavaScriptGlobalName = "__swiftWebBridge"
public var messageHandlerName: String
public var javaScriptGlobalName: String
}Controls the names used for the WebKit handler and injected JavaScript global.
let bridge = WebViewBridge(
configuration: .init(
messageHandlerName: "myBridge",
javaScriptGlobalName: "__myBridge"
)
)The TypeScript side must use the same names:
const bridge = createBridge({
messageHandlerName: "myBridge",
globalName: "__myBridge",
});public protocol BridgeEventPayload: Codable {
static var eventType: String { get }
}Conform payload types to this protocol when the Swift event name should live next to the payload definition.
struct PromptSubmittedPayload: BridgeEventPayload {
static let eventType = "promptSubmitted"
}public struct EmptyPayload: Codable, Equatable {
public init()
}Use for events that carry no data, such as refresh or submit messages.
public final class BridgeSubscription {
public func cancel()
}Returned by bridge.on(...). Calling cancel() removes the handler. Cancellation is idempotent.
public extension View {
func bridgeSubscribe<P: Decodable>(
_ type: String,
_ payload: P.Type = P.self,
_ bridge: WebViewBridge,
handler: @escaping (P) -> Void
) -> some View
func bridgeSubscribe<P: BridgeEventPayload>(
_ payloadType: P.Type = P.self,
_ bridge: WebViewBridge,
handler: @escaping (P) -> Void
) -> some View
}Subscribes on onAppear and cancels on onDisappear.
.bridgeSubscribe("menuPresented", MenuPresentedPayload.self, bridge) { payload in
isPresented = payload.presented
}
.bridgeSubscribe(MenuPresentedPayload.self, bridge) { payload in
isPresented = payload.presented
}public extension View {
func syncedReactState<Value: Codable & Equatable>(
_ key: String,
_ binding: Binding<Value>,
_ bridge: WebViewBridge
) -> some View
@available(*, deprecated, renamed: "syncedReactState(_:_:_:)")
func reactSyncedState<Value: Codable & Equatable>(
_ key: String,
_ binding: Binding<Value>,
_ bridge: WebViewBridge
) -> some View
@available(*, deprecated, renamed: "syncedReactState(_:_:_:)")
func reactSynced<Value: Codable & Equatable>(
_ key: String,
_ binding: Binding<Value>,
_ bridge: WebViewBridge
) -> some View
}Synchronizes a SwiftUI binding with React's useNativeState(...).
For key "selectedIndex", the modifier listens for:
state.selectedIndexstate.selectedIndex.refresh
It sends the current value on appear, sends again when the Swift binding changes, and replies to refresh requests from React.
reactSyncedState(...) and reactSynced(...) are deprecated aliases for syncedReactState(...).
function createBridge<Events extends object = BridgeEventMap>(
options?: BridgeOptions
): Bridge<Events>;Creates a typed bridge instance. Pass an event map to type event names and payloads.
type Events = {
menuPresented: { presented: boolean };
promptSubmitted: EmptyPayload;
};
const bridge = createBridge<Events>();
bridge.send("menuPresented", { presented: true });
bridge.on("promptSubmitted", () => {});const bridge: Bridge<BridgeEventMap>;A default untyped singleton bridge. It is convenient for simple apps. Prefer createBridge<Events>() when you want event-name and payload checking.
function injectBridge(options?: BridgeOptions): void;Calls bridge.inject(options) on the default singleton.
interface Bridge<Events extends object = BridgeEventMap> {
on<Type extends BridgeEventName<Events>>(
type: Type,
handler: (payload: Events[Type]) => void
): Unsubscribe;
send<Type extends BridgeEventName<Events>>(
type: Type,
payload: Events[Type]
): void;
inject(options?: BridgeOptions): void;
dispatch<Type extends BridgeEventName<Events>>(
message: BridgeMessage<Type, Events[Type]>
): void;
}on(...) registers a TypeScript-side handler and returns an unsubscribe function.
send(...) posts { type, payload } to window.webkit.messageHandlers[messageHandlerName] when WebKit is present.
inject(...) creates window[globalName]._dispatch(...), which Swift calls when sending messages to TypeScript. Call this once during app startup.
dispatch(...) manually invokes TypeScript handlers. This is useful for tests and non-WebKit adapters.
interface BridgeOptions {
messageHandlerName?: string;
globalName?: string;
target?: Window;
}messageHandlerName defaults to swiftWebBridge.
globalName defaults to __swiftWebBridge.
target defaults to window when available. Supplying a target is useful in tests.
type Unsubscribe = () => void;
type BridgeEventMap = Record<string, unknown>;
type BridgeEventName<Events extends object> = Extract<keyof Events, string>;
type EmptyPayload = Record<string, never>;EmptyPayload represents {} for events with no data.
type BridgeMessage<Type extends string = string, Payload = unknown> = {
type: Type;
payload: Payload;
};The raw message envelope used by dispatch(...).
type NativeStateEventMap<State extends object> = {
[Key in Extract<keyof State, string> as `state.${Key}`]: State[Key];
} & {
[Key in Extract<keyof State, string> as `state.${Key}.refresh`]: EmptyPayload;
};Builds the event names used by useNativeState(...) and .syncedReactState(...).
type NativeState = {
draft: string;
selectedIndex: number;
};
type Events = NativeStateEventMap<NativeState> & {
promptSubmitted: EmptyPayload;
};React helpers are exported from swift-web-bridge/react.
function useNativeState<T>(
key: string,
initial: T,
options?: UseNativeStateOptions
): [T, NativeStateSetter<T>];Creates React state that syncs with Swift's .syncedReactState(key, binding, bridge).
const [draft, setDraft] =
useNativeState<string>("draft", "", { bridge });For key "draft", the hook listens for:
state.draft
It sends:
state.draft.refreshon mountstate.draftwhenever the setter is called
The setter supports direct values and updater functions:
setDraft("Hello");
setDraft((previous) => previous + "!");For typed native state maps:
type NativeState = {
draft: string;
selectedIndex: number;
};
const [selectedIndex, setSelectedIndex] =
useNativeState<NativeState, "selectedIndex">(
"selectedIndex",
0,
{ bridge }
);type NativeStateSetter<T> = (next: T | ((prev: T) => T)) => void;
type ReactBridge = {
on(type: any, handler: (payload: any) => void): () => void;
send(type: any, payload: any): void;
};
interface UseNativeStateOptions {
bridge?: ReactBridge;
}Pass options.bridge when you use a typed bridge created by createBridge<Events>(). If omitted, the hook uses the default singleton bridge.
Swift-to-TypeScript messages call:
window.__swiftWebBridge._dispatch({ type, payload });TypeScript-to-Swift messages call:
window.webkit.messageHandlers.swiftWebBridge.postMessage(
JSON.stringify({ type, payload })
);The package does not perform runtime schema validation. Swift uses Codable for decode/encode. TypeScript event maps provide compile-time checking.
swift test
npm install
npm test
npm run typecheck
npm run build
npm pack --dry-runReleases are driven by bare SemVer git tags such as 0.2.0. The tag is the SwiftPM/SPI release, and GitHub Actions publishes the matching npm package. See RELEASING.md for the full workflow.
See Examples/MinimalBridgeDemo for a small macOS SwiftUI app and Vite React counter UI that demonstrates synced state, Swift-to-web commands, and web-to-Swift events.