SwiftWebBridge

main

AndrewPrifer/swift-web-bridge

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 SwiftWebBridge

npm

npm install swift-web-bridge

The 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, WKScriptMessageHandler

The 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
) -> BridgeSubscription

Subscribes 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
) -> BridgeSubscription

Subscribes to an explicit event name and decodes the envelope payload as P.

@discardableResult
public func receive(_ text: String) -> Bool

Feeds 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",
});

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.selectedIndex
  • state.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.refresh on mount
  • state.draft whenever 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-run

Releasing

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.

Description

  • Swift Tools 5.9.0
View More Packages from this Author

Dependencies

  • None
Last updated: Sun Jun 21 2026 23:51:03 GMT-0900 (Hawaii-Aleutian Daylight Time)