One Swift ViewModel, two front-ends — with the web bridge generated from an annotation.
This sample takes a single @Observable view model — written once in Swift, using
BSWFoundation for networking and storage — and drives
both a native iOS app and a browser app from it. The web version compiles the very same Swift
to WebAssembly (SwiftWasm) and lets React render the UI.
No logic is duplicated. And the Swift↔JS bridge isn't hand-written: you mark the view model with a
// SKIP @bridge comment — the same annotation Skip uses to bridge Swift to
Android — and a small generator emits the WebAssembly bridge. Annotate once, bridge to both.
DemoCore.ViewModel shared logic: APIClient, storage, @Observable state
│ // SKIP @bridge (no UI, no platform assumptions — runs on Apple AND wasm)
├─ DemoUI SwiftUI ───► BSWDemo.app (native iOS/macOS)
└─ DemoBridge generated ───► BSWDemoReact (React website; Swift compiled to wasm)
▲
└─ BridgeJSGen reads the marker → @JS wrapper → BridgeJS → typed .d.ts
DemoCore— the reusableViewModel+ API definitions. Platform-agnostic; theViewModelcarries an inert// SKIP @bridgemarker.DemoUI— a SwiftUIContentViewbound to the view model. Apple platforms only.DemoBridge— a headless wasm executable. Its JS-facing@JSwrapper is generated (not hand-written) from the marker, then turned into a typed JS/TS API by JavaScriptKit's BridgeJS plugin.BSWDemoReact— a Vite + React + TypeScript app that constructs the generatedViewModelBridgeand renders its state.@theleftbit/swift-react(packages/swift-react) — reusable React primitives (useViewModel,<AsyncView>) that own a bridged ViewModel's lifecycle. The React tier of BSWInterfaceKit — shared infra the app depends on, not app code.Tools/BridgeJSGen— a small SwiftSyntax generator: reads// SKIP @bridgemarkers and emits@JSwrappers. The WASM analog of Skip'sskipstone. (Proof of concept — see caveats.)
| Path | What |
|---|---|
BSWDemo.xcodeproj / BSWDemo/ |
The native app shell (@main), wired to the local BSWDemoKit package |
BSWDemoKit/ |
The Swift package: DemoCore, DemoUI, DemoBridge targets |
BSWDemoReact/ |
The Vite + React app that renders the generated bridge |
packages/swift-react/ |
@theleftbit/swift-react — reusable React primitives (useViewModel, AsyncView) the app consumes |
Tools/BridgeJSGen/ |
The marker → @JS wrapper generator |
Requires Xcode 26 or later (the UI uses iOS 26 SwiftUI APIs such as .safeAreaBar).
- Open
BSWDemo.xcodeproj. - Xcode resolves the Swift packages automatically — the local
BSWDemoKit, andBSWFoundationpulled from itsfeature/wasm-portbranch. (First resolve needs network access.) - Pick an iOS 26 simulator and hit Run (⌘R).
That's it — a standard SPM-backed app. The // SKIP @bridge marker is a plain comment, so it has no
effect here.
-
A WebAssembly Swift SDK that matches your toolchain. Check your version with
swift --version, then install the matching wasm SDK — see swift.org's WebAssembly guide for the current URL + checksum (BSWFoundation's README has a worked example). Then note the SDK id:swift sdk list # e.g. swift-6.3.2-RELEASE_wasm -
Node.js 18+ — for the Vite dev server.
Two steps: generate the bridge from the marker, then compile to wasm. From the repo root:
# 1. Generate the @JS wrapper from the `// SKIP @bridge` marker on DemoCore.ViewModel.
swift run --package-path Tools/BridgeJSGen BridgeJSGen \
BSWDemoKit/Sources/DemoCore/ViewModel.swift \
BSWDemoKit/Sources/DemoBridge/Generated/ViewModelBridge.swift \
DemoCore
# 2. Compile DemoBridge to wasm; BridgeJS generates the typed JS/TS bindings automatically.
swift package --package-path BSWDemoKit \
--swift-sdk swift-6.3.2-RELEASE_wasm \
--disable-sandbox \
js --use-cdn --product DemoBridge \
--output BSWDemoReact/public/swiftReplace
swift-6.3.2-RELEASE_wasmwith the id from yourswift sdk list. BothDemoBridge/Generated/andBSWDemoReact/public/swift/are git-ignored build artifacts — regenerate them, don't commit.--use-cdnfetches the small@bjorn3/browser_wasi_shimruntime from a CDN. This is a debug bundle (~80 MB) — fine for local dev; see Shrinking the bundle for deploys.
cd BSWDemoReact
npm install
npm run devOpen the printed URL (default http://localhost:5173): the IP address, a counter, and a
random number that ticks every second — all produced by the Swift view model through the generated
bridge — plus a Bump Counter button that drives ViewModel.bump() in Swift.
npm run build outputs a static site to BSWDemoReact/dist/ (the wasm bundle and loader are copied
in). Serve that folder with any static host.
From the JavaScript side there are just three things: initialize the runtime, get a Swift
object, call it. All of it is typed — BridgeJS emits a .d.ts describing the exported API.
The Swift model (DemoCore/ViewModel.swift), reduced
to its public surface:
// SKIP @bridge
@Observable @MainActor
public final class ViewModel {
public var ipAddress: String // fetched once at init
public var randomNumber: Int // changes on a timer
public var counter: Int { get set } // persisted (localStorage on wasm)
public init() async throws // does the network fetch
public func bump()
}…is what JavaScript sees (generated bridge-js.d.ts):
declare function bootstrapSwiftRuntime(): void
declare function createViewModelBridge(): Promise<ViewModelBridge>
interface ViewModelBridge {
readonly ipAddress: string // public var ipAddress: String
readonly counter: number // public var counter: Int
readonly randomNumber: number // public var randomNumber: Int
bump(): void // public func bump()
subscribe(onChange: () => void): void // push: fires on every @Observable change
release(): void // free the Swift object (no GC across wasm)
}How the surface maps:
| Swift | JavaScript / TypeScript |
|---|---|
public var x: String / Int |
readonly x: string / number — read on demand |
an @Observable change |
subscribe(onChange) — pushed to JS, so it's reactive (no polling) |
public func bump() |
bump(): void |
public init() async throws |
createViewModelBridge(): Promise<ViewModelBridge> — the async init stays one code path |
| the class instance | a SwiftHeapObject handle; call release() when done |
Only the public surface crosses;
private/@ObservationIgnoredmembers (the property-wrapper storage, the timerTask) stay inside Swift.@MainActoris handled by the generated wrapper.
The compiled Swift is an ES module (/swift/index.js) whose init() instantiates the wasm and
returns the exported API. In a Vite app, load it from a /public module — Vite won't let you
import() a /public file from your src code, so a tiny boot script sidesteps that. Bootstrap
happens here, once, and hands back the ready exports:
// BSWDemoReact/public/boot-swift.js — referenced from index.html via
// <script type="module" src="/boot-swift.js"></script>
import { init } from "/swift/index.js"
window.swiftReady = (async () => {
const { exports } = await init() // instantiate the wasm module
exports.bootstrapSwiftRuntime() // install Swift's concurrency runtime — ONCE
return exports // hand back the API; callers create objects on demand
})()bootstrapSwiftRuntime() installs the JS event-loop executor Swift concurrency needs — the
per-launch step. window.swiftReady resolves to the bridge's exports, independent of any
particular object, so it's created once and reused for everything you make.
With the runtime up, each Swift object comes from its factory. createViewModelBridge() is an async
function returning Promise<ViewModelBridge> that constructs the Swift ViewModel (network fetch and
all). Call it per instance — the bootstrap above is not repeated:
const swift = await window.swiftReady // the bootstrapped exports (step 1)
const vm = await swift.createViewModelBridge() // one ViewModel; call again for morevm is a live handle to that Swift instance — every read and call below hits it. A bigger app makes
many objects (createUserBridge(), createFeedBridge(), …) off the same swiftReady.
The object is fully typed (straight from the generated .d.ts):
interface ViewModelBridge {
readonly ipAddress: string // Swift String → string
readonly counter: number // Swift Int → number
readonly randomNumber: number
bump(): void // calls Swift ViewModel.bump()
subscribe(onChange: () => void): void // push: called on every @Observable change
release(): void // free the Swift object when you're done with it
}- Read properties directly —
vm.counter,vm.ipAddress(read on demand). - React to changes with
vm.subscribe(onChange)— the Swift side callsonChangeon every@Observablechange (viaObservable.stream(for:)), so you re-read and re-render. Real push, no polling; pairs naturally with React'suseSyncExternalStore. - Call methods directly —
vm.bump(). - Release with
vm.release()when the object is no longer needed (it holds a Swift heap allocation; there's no GC across the wasm boundary).
In practice you don't wire subscribe/release by hand. The
@theleftbit/swift-react package provides useViewModel and
<AsyncView> — the React tier of BSWInterfaceKit (the same role AsyncView plays on iOS/Android,
and analogous to Polymarket's SwiftViewModelUtils). The hook creates the model once the runtime is
up, re-renders on every change, and release()s it on unmount, so a component carries no
lifecycle code:
import { useViewModel, AsyncView } from "@theleftbit/swift-react"
const vmState = useViewModel<ViewModelBridge>((swift) => swift.createViewModelBridge())
return (
<AsyncView state={vmState}>
{(vm) => (
<>
<p>IP {vm.ipAddress} · counter {vm.counter} · random {vm.randomNumber}</p>
<button onClick={() => vm.bump()}>Bump</button>
</>
)}
</AsyncView>
)No subscribe, no release, no loading boilerplate in the component — those live in the package.
Working version: packages/swift-react and
App.tsx.
Adding more to the API? Everything above is generated from the
// SKIP @bridgemarker — see How the bridge works for the Swift side.
The web bridge is generated, not hand-written — the DX mirrors Skip's Android bridging:
- Mark the model.
DemoCore.ViewModelcarries a// SKIP @bridgecomment. It's inert to the Swift compiler, so it neither couplesDemoCoreto JavaScriptKit nor affects the iOS build — exactly like Skip's marker for Android. - Generate the wrapper.
Tools/BridgeJSGen(a SwiftSyntax tool) reads the marker and emits a@JS-annotated wrapper into the wasm-onlyDemoBridgetarget — an asynccreateViewModelBridge()factory, typed getters, andbump(). - Generate the bindings. JavaScriptKit's BridgeJS plugin turns the
@JSwrapper into wasm exports plus a typed TypeScript.d.ts:interface ViewModelBridge { readonly ipAddress: string; readonly counter: number; bump(): void } function bootstrapSwiftRuntime(): void function createViewModelBridge(): Promise<ViewModelBridge>
- Consume it from JS. The boot script runs
init()+bootstrapSwiftRuntime()once; React then creates aViewModelBridge(and could create more) and reads its typed getters / callsbump()— see Using the Swift ViewModel from React.
Design notes:
- The
@MainActorview model stays fully main-actor; the generated wrapper is a nonisolated,@unchecked Sendablefacade that hops to the main actor (safe — wasm is single-threaded). bootstrapSwiftRuntime()installs the JS event-loop executor once per app launch — the WASM analog of Skip'sProcessInfo.launch(_:), not per-object.- The
ViewModelkeeps its singleinit() async throws; the generatedcreate…factory is the bridged construction path (mirrors Skip'screate(...)pattern).
Key files:
- Shared model (marked) —
DemoCore/ViewModel.swift - SwiftUI view —
DemoUI/ContentView.swift - Generator —
Tools/BridgeJSGen - React primitives —
packages/swift-react—useViewModel+AsyncView, published as@theleftbit/swift-react - React view —
BSWDemoReact/src/App.tsx
BridgeJSGenis a proof of concept. It handles marked classes with public typed properties (read-only getters), no-argVoidmethods, an async initializer, and asubscribe(onChange:)driven byObservable.stream(for:). Methods with arguments, richer types, and structs/enums would need more work. A production setup would wire it as a SwiftPM build-tool plugin (likeskipstone) so it regenerates on every build — then nothing generated ever lands in git.- Reactivity is push-based:
subscribe(onChange:)— a BridgeJS-exported closure — fires on every@Observablechange viaObservable.stream(for:), so React re-renders without polling. The property reads themselves are still on-demand getters. BridgeJSis experimental (JavaScriptKit) — APIs may change.- The
BSWFoundationdependency points at thefeature/wasm-portbranch while WASM support is in review. Swap it to a released version once merged (BSWDemoKit/Package.swift). UserDefaultsBacked<Int>isn't available on wasm (onlyBool/String); the view model usesCodableUserDefaultsBacked, which works everywhere (localStoragein the browser).localStorageis not secure storage — fine for a demo counter, not for secrets.
The debug wasm is large. For a real deploy, build in release with
Binaryen's wasm-opt on your PATH, then serve with
brotli/gzip — this drops the served size dramatically (~80 MB → ~12 MB). Add -c release to the
js command above. See BSWFoundation's
Production builds & binary size
for the details.