Skip to content

Repository files navigation

BSWDemo

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.

Architecture

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 reusable ViewModel + API definitions. Platform-agnostic; the ViewModel carries an inert // SKIP @bridge marker.
  • DemoUI — a SwiftUI ContentView bound to the view model. Apple platforms only.
  • DemoBridge — a headless wasm executable. Its JS-facing @JS wrapper 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 generated ViewModelBridge and 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 @bridge markers and emits @JS wrappers. The WASM analog of Skip's skipstone. (Proof of concept — see caveats.)

Repo layout

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

Part 1 — Run the native app (the easy part)

Requires Xcode 26 or later (the UI uses iOS 26 SwiftUI APIs such as .safeAreaBar).

  1. Open BSWDemo.xcodeproj.
  2. Xcode resolves the Swift packages automatically — the local BSWDemoKit, and BSWFoundation pulled from its feature/wasm-port branch. (First resolve needs network access.)
  3. 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.


Part 2 — Run the React website (Swift → WebAssembly)

Prerequisites

  • 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.

Build the Swift → wasm bundle

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/swift

Replace swift-6.3.2-RELEASE_wasm with the id from your swift sdk list. Both DemoBridge/Generated/ and BSWDemoReact/public/swift/ are git-ignored build artifacts — regenerate them, don't commit. --use-cdn fetches the small @bjorn3/browser_wasi_shim runtime from a CDN. This is a debug bundle (~80 MB) — fine for local dev; see Shrinking the bundle for deploys.

Serve it

cd BSWDemoReact
npm install
npm run dev

Open 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.

Production build

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.


Using the Swift ViewModel from React

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 ViewModel, bridged

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/@ObservationIgnored members (the property-wrapper storage, the timer Task) stay inside Swift. @MainActor is handled by the generated wrapper.

1. Bootstrap the runtime — once per app launch

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.

2. Create objects — as many as you need

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 more

vm 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.

3. Read state and call methods

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 calls onChange on every @Observable change (via Observable.stream(for:)), so you re-read and re-render. Real push, no polling; pairs naturally with React's useSyncExternalStore.
  • 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 @bridge marker — see How the bridge works for the Swift side.


How the bridge works (marker-driven)

The web bridge is generated, not hand-written — the DX mirrors Skip's Android bridging:

  1. Mark the model. DemoCore.ViewModel carries a // SKIP @bridge comment. It's inert to the Swift compiler, so it neither couples DemoCore to JavaScriptKit nor affects the iOS build — exactly like Skip's marker for Android.
  2. Generate the wrapper. Tools/BridgeJSGen (a SwiftSyntax tool) reads the marker and emits a @JS-annotated wrapper into the wasm-only DemoBridge target — an async createViewModelBridge() factory, typed getters, and bump().
  3. Generate the bindings. JavaScriptKit's BridgeJS plugin turns the @JS wrapper 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>
  4. Consume it from JS. The boot script runs init() + bootstrapSwiftRuntime() once; React then creates a ViewModelBridge (and could create more) and reads its typed getters / calls bump() — see Using the Swift ViewModel from React.

Design notes:

  • The @MainActor view model stays fully main-actor; the generated wrapper is a nonisolated, @unchecked Sendable facade 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's ProcessInfo.launch(_:), not per-object.
  • The ViewModel keeps its single init() async throws; the generated create… factory is the bridged construction path (mirrors Skip's create(...) pattern).

Key files:

Notes & caveats

  • BridgeJSGen is a proof of concept. It handles marked classes with public typed properties (read-only getters), no-arg Void methods, an async initializer, and a subscribe(onChange:) driven by Observable.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 (like skipstone) 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 @Observable change via Observable.stream(for:), so React re-renders without polling. The property reads themselves are still on-demand getters.
  • BridgeJS is experimental (JavaScriptKit) — APIs may change.
  • The BSWFoundation dependency points at the feature/wasm-port branch while WASM support is in review. Swap it to a released version once merged (BSWDemoKit/Package.swift).
  • UserDefaultsBacked<Int> isn't available on wasm (only Bool/String); the view model uses CodableUserDefaultsBacked, which works everywhere (localStorage in the browser).
  • localStorage is not secure storage — fine for a demo counter, not for secrets.

Shrinking the bundle

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.

About

One Swift ViewModel powering both a native SwiftUI app and a React website (compiled to WebAssembly via SwiftWasm). A BSWFoundation sample.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages