swift-web-bridge
v0.2.0
Published
A small WKWebView bridge for Swift apps with TypeScript and React helpers.
Downloads
301
Maintainers
Readme
SwiftWebBridge
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.
Installation
Swift Package Manager
Add this repository to your app with Swift Package Manager and import the library:
import SwiftWebBridgenpm
npm 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";Quick Start
Swift
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))TypeScript
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 }
);Defaults
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.
Swift API
WebViewBridge
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.
WebViewBridgeConfiguration
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",
});WebViewSource And WebViewSourceLoader
public indirect enum WebViewSource {
case bundle(Bundle, subdirectory: String? = nil, indexFile: String = "index.html")
case remote(URL)
case development(URL, fallback: WebViewSource, timeout: TimeInterval = 0.5)
}
public final class WebViewSourceLoader {
public init(source: WebViewSource)
public func load(
into webView: WKWebView,
completion: ((Result<WebViewSourceLoadTarget, Error>) -> Void)? = nil
)
}WebViewSourceLoader handles the reusable WebView loading policy without taking over WKNavigationDelegate or WKUIDelegate. This keeps app-specific behavior such as file upload, alerts, navigation decisions, and focus tracking in your own WebView wrapper.
Use .development(...) to load a local dev server in debug builds and fall back to bundled assets when the server is unavailable. In release builds, .development(...) always loads the fallback.
let source = WebViewSource.development(
URL(string: "http://localhost:5173")!,
fallback: .bundle(.main, subdirectory: "composer/dist")
)
let configuration = WKWebViewConfiguration()
bridge.install(in: configuration)
let webView = WKWebView(frame: .zero, configuration: configuration)
webView.navigationDelegate = navigationDelegate
webView.uiDelegate = uiDelegate
bridge.attach(to: webView)
WebViewSourceLoader(source: source).load(into: webView)Use .bundle(.module, subdirectory: "WebDist") from Swift package examples or package resources. Use .bundle(.main, subdirectory: "...") from an app target.
BridgeEventPayload
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"
}EmptyPayload
public struct EmptyPayload: Codable, Equatable {
public init()
}Use for events that carry no data, such as refresh or submit messages.
BridgeSubscription
public final class BridgeSubscription {
public func cancel()
}Returned by bridge.on(...). Calling cancel() removes the handler. Cancellation is idempotent.
SwiftUI Modifiers
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(...).
TypeScript API
createBridge
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", () => {});bridge
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.
injectBridge
function injectBridge(options?: BridgeOptions): void;Calls bridge.inject(options) on the default singleton.
Bridge
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.
BridgeOptions
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.
Types
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 API
React helpers are exported from swift-web-bridge/react.
useNativeState
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 }
);React Types
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.
Protocol Notes
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.
Development
swift test
npm install
npm test
npm run typecheck
npm run build
npm pack --dry-runReleasing
Releases 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.
Demo
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.
