SwiftWeb: full-stack web apps in Swift — WASM client islands and distributed actors (developer preview)

Hi everyone,

I'd like to share a project I've been building: SwiftWeb, an HTML-first web framework for writing full-stack web apps entirely in Swift — server rendering, typed routing, server actions, WebAssembly client islands, and distributed actors — plus a Cloudflare adapter that runs those actors inside Durable Objects.

It's a developer preview (swift-web 0.2.1). Requirements are honest but quirky right now — details below.

Live demo: Japan Event Calendar — a SwiftWeb app running on Cloudflare Workers. It lists sightseeing events across Japan on a calendar, server-rendered and localized into 14 languages, so you can browse it in yours.

The model

Apps follow SwiftUI's shape: App / Scene / @Page.

public struct MyApp: App {
    public var body: some Scene {
        HomePage()
    }
}

@Page("/")
struct HomePage {
    func body() -> some HTML {
        div {
            h1 { "Hello SwiftWeb" }
            p { "Rendered by SwiftHTML and served through SwiftWeb." }
        }
    }
}

Pages render to HTML on the server by default — no JavaScript is shipped. Interactive parts are declared as ClientComponent and run in the browser as WASM (islands architecture), with SwiftUI-style @State:

struct ClientCounter: ClientComponent, Sendable {
    @State private var count = 0

    var body: some HTML {
        Button("Count \(count)") {
            count += 1  // runs in the browser
        }
    }
}

Each island declares when it loads (.eager / .visible / .interaction / .idle / .manual) and which logical WASM bundle it belongs to.

Distributed actors as the client-server boundary

This is the part I'd most like feedback on. A service contract is a @Resolvable protocol, the server implements it as an ordinary distributed actor, and a client component receives the resolved actor through @Actor — then calls it directly from the browser:

@Resolvable
protocol CounterServiceProtocol: DistributedActor
where ActorSystem == WebActorSystem {
    distributed func increment() async throws -> Int
}

struct CounterClient: ClientComponent {
    @Actor private var counter: any CounterServiceProtocol

    func increment() async throws -> Int {
        try await counter.increment()  // browser WASM → server actor
    }
}

There is no fetch, no endpoint definition, and no serialization code in user code — WebActorSystem handles the transport over HTTP (/_swiftweb/actors/invoke) and WebSocket (/_swiftweb/actors/ws), including server → client push.

The Cloudflare adapter routes each actor identity to its own Durable Object via idFromName, so actor state persists across calls. Durable Objects' one-instance-per-id model maps surprisingly cleanly onto distributed actor identity. This is verified end-to-end in workerd: envelope POST → Worker routing → per-identity DO → activation → state across calls, plus WebSocket sessions with server-side pushes.

Server actions

For mutations that should stay on the server, there's a macro-based server action layer:

actor CounterActions: PageOwnedServerActions {
    @ServerAction(.post, "increment")
    func increment(_ input: NoActionInput, context: ActionInvocationContext) async throws -> ActionResult {
        _ = try await counterService.increment()
        return .invalidate(.page)
    }
}

.invalidate(.page) re-renders the page while preserving client island @State — only server-owned DOM is merged.

Ecosystem notes and requirements

  • The host HTTP stack is built on swift-http-server (NIOHTTPServer) and swift-http-api-proposal (HTTPAPIs). Both declare tools-version 6.4 and enable LifetimeDependence, so host builds currently require a Swift 6.4 toolchain (e.g. Xcode-beta). This is the main reason SwiftWeb ships as a developer preview — it rides the still-evolving next-generation server packages. Once 6.4 goes GA this friction disappears.
  • Browser WASM builds use the Swift 6.3.1 release toolchain with the swift-6.3.1-RELEASE_wasm SDK, and JavaScriptKit for the DOM bridge.
  • Embedded Swift WASM is not supported — the browser graph needs Distributed, Codable, and Foundation. Production WASM builds strip debug sections, run wasm-opt, and emit .gz / .br sidecars instead.
  • There's also a Vapor host adapter (swift-web-vapor), branch-pinned until Vapor 5 ships a stable release.

Trying it

mint install 1amageek/swift-web@0.2.1 sweb
sweb new MyApp --output MyApp
cd MyApp && sweb dev

A longer walkthrough is in my blog post: Building and Running Web Apps with Just Swift.

Feedback I'm looking for

  • The @Actor / .actor() injection API on top of Apple's @Resolvable distributed actor model — does the shape feel right?
  • The WebSocket actor transport design, especially server → client push semantics
  • Whether the per-identity Durable Object mapping holds up for real workloads
  • General API shape of @Page, PageGroup, and the server action macros

Design docs live in the repo (Platform Host Architecture, Actor Injection Design, WebSocket Actor Transport Design).

Feedback and PRs are very welcome. Thanks!

12 Likes

Hi there!
Firstly, awesome work! I've been waiting for quite some time for someone to pull this phoenix/live-view style integration with web views and distributed actors, it's a great match imho and seems you're heading the right direction from a quick skim? :-)

I've not browsed much yet since I'm in the middle of packing for a holiday right now, but I'll have a deeper look when I'm back.

  • The @Actor / .actor() injection API on top of Apple's @Resolvable distributed actor model — does the shape feel right?

My only nit with this is about names so far, though I'd need to dig deeper how you assign identity. I'd probably call it something Server or Remote or something since that's what you're doing there right? Just "Actor" doesn't really mean much other than "thing that isolates state", but that's any actor, and you're doing something more specific here, so I'd look into naming here -- maybe look what LiveView are doing for inspiration?

There's two kinds of "distributed actor on the server backing my view" you might want -- one is persistent for the user, or session, or just purely ephemeral, so I don't know how you're thinking about these here. I like to think of them like a mini cache with a page's or app's data, that can even be live computing and pushing updates to the associated user/site via ws messages... Are you thinking about this as well?

You'd be able to have @State like properties, but their computation is on the server in that actor associated with given view or session or user etc.

I've not had time yet, but this is another thing I'd probably have a look at live view and how they do the diffing of state and pushing updates from server :thinking:

I'd even say that not by accident! The actor model and the id per stateful entity is a great model and there's been a bunch of systems leaning into it, like Akka Persistence, Orleans, Azure durable functions and now Durable Object as well. It's a great concept to build things on, and I'm glad you found the model matches Cloudflare's nicely here.

--

Overall really cool, and I hope you'll continue working on these! I'll try to have a deeper look sometime soon, thanks for exploring these exciting use cases of actors! :slight_smile:

1 Like

wow, there is lot going on in there, I will definitely take a closer look at this when I have some play time. congrats!

I am especially curious in how well the CSS lowering of your SwiftUI layer works, because I have tried a few times (for ElementaryUI) and could never really get the layout to work well enough (spacers, .frame, nested stacks....). Honestly, I still think it's impossible to get it "good enough" by just rendering out HTML and CSS, but I really hope I am wrong and that you actually pulled it off!

1 Like

Thanks so much for taking the time to look at this right before a holiday — and for the LiveView framing, which is exactly the lineage I had in mind. Enjoy the time off!

On naming — you're right, and it's becoming @RemoteActor

I'd probably call it something Server or Remote or something since that's what you're doing there right?

Agreed. I've settled on @RemoteActor, shipped in 0.6.0 — a clean break rather than a deprecation shim, while the project is still a developer preview:

  • It stays inside the Distributed vocabulary — resolve hands you a possibly-remote reference and invocations are remote calls. The wrapper names the authoring-model perspective: from the island's point of view, the actor lives across the web boundary. (During SSR the same property may resolve locally — location transparency — but the component author's mental model is "remote", and that's what the name should serve.)
  • @ServerActor lost for a mundane reason: SwiftWeb already has @ServerAction, and an edit distance of 1 between two core attributes is an autocomplete accident waiting to happen.

On lifetimes — identity as a small algebra, not an enum

There's two kinds of "distributed actor on the server backing my view" you might want — one is persistent for the user, or session, or just purely ephemeral

Working through this question, the design landed somewhere I like: instead of enumerating scopes (per-user / per-session / per-tenant / per-entity / …), model where the identity key comes from. Three primitives:

// server derives the key from trusted context — authorization is identity-match, automatic
ActorGroup(scope: .derived(\.userID)) { ChatAgent(actorSystem: $0) }

// client addresses the key — an authorization policy is required by the type system
ActorGroup(scope: .addressed(authorization: RoomMembership())) { ChatRoom(actorSystem: $0) }

// no identity — lives and dies with the connection
ActorGroup(scope: .transient) { Autocomplete(actorSystem: $0) }

The familiar cases are presets over .derived (.application / .user / .tenant / .session), and your persistent-vs-ephemeral split falls out of .derived/.addressed vs .transient. Two things I expected to be hard turned out to be corollaries:

  • Composite identities — "a user's private draft of a shared document" — are concatenation: .derived(\.userID) + .addressed(authorization: DraftPolicy())contract:user:<uid>:doc:<id>, with the segment authorization rules AND-ed.
  • Sharding a hot entity is just another derivation function: .derived(.shard(of: \.userID, count: 100)).

The security property I care most about here: with .derived the client cannot even spell another user's key, and with .addressed, forgetting authorization is a compile error rather than a silently-open endpoint.

The time axis — passivation and durable reminders

I like to think of them like a mini cache with a page's or app's data, that can even be live computing and pushing updates

That's exactly the model — and it needs the time dimension to be explicit, orthogonal to identity:

  • Passivation: .passivation(.afterIdle(.minutes(5))) with optional activated() / passivating() hooks — the Orleans OnActivate/OnDeactivate shape, Akka Cluster Sharding's passivation knob.
  • Durable reminders in the Orleans sense (as distinct from timers): they fire while the actor is passivated and re-activate it. Cloudflare happens to ship the perfect host primitive in Durable Object Alarms, so the host-neutral API lowers onto it directly.

And yes — not by accident: @ActorStorage's own doc comment describes itself as "the actor's grain state in the Orleans sense". The lineage is deliberate. The one thing we consciously didn't take is supervision trees: for request-driven web actors, the virtual-actor model — the actor always exists, activation on demand — does the job supervision does in long-lived process trees.

Where it converges: a Foundation Models agent as a durable actor

I like to think of them like a mini cache with a page's or app's data, that can even be live computing and pushing updates to the associated user/site via ws messages... You'd be able to have @State like properties, but their computation is on the server

Here's the concrete shape everything above converges on, written against the OS 27 Foundation Models API — which is going open source, so the same session API can run wherever Swift runs. Three facts line up almost suspiciously well:

  • Transcript is Codable & Sendable — exactly @ActorStorage's requirement. The conversation itself can be the grain state, with no mapping layer.
  • LanguageModelSession(model:tools:transcript:) is documented as "rehydrating from a transcript" — actor activation and session rehydration become the same moment.
  • ResponseStream yields typed snapshots of partial generation — precisely the unit @RemoteState wants to push.
@ResolvableActor(TravelAgentProtocol.self)
distributed actor TravelAgent: TravelAgentProtocol {
    typealias ActorSystem = WebActorSystem

    @ActorStorage("transcript") var transcript = Transcript()   // the conversation IS the grain state
    @RemoteState("partial") var partial: String?                // streamed to the island over WS

    private var session: LanguageModelSession?

    func activated() async {
        session = LanguageModelSession(
            model: model,                 // any LanguageModel provider
            tools: [SearchEvents()],      // a Foundation Models Tool, running next to the data
            transcript: transcript        // rehydration == activation
        )
    }

    func passivating() async {
        if let session { transcript = session.transcript }
    }

    distributed func send(_ text: String) async throws -> String {
        guard let session else { throw AgentError.notActivated }
        var latest = ""
        for try await snapshot in session.streamResponse(to: text) {
            latest = snapshot.content
            partial = latest              // typed partial generation → WS push → island re-renders
        }
        partial = nil
        transcript = session.transcript
        return latest
    }
}
ActorGroup(scope: .derived(\.userID)) { TravelAgent(actorSystem: $0) }
    .passivation(.afterIdle(.minutes(5)))
// a Scene modifier — composes onto groups and individually exported actors alike

On the island, the entire "resume" path is one line:

.task { transcript = try await agent.history() }
// First visit: empty. Days later, on another device: activation rehydrates
// the session and the full conversation returns. There is no branch.

Close the tab and the WebSocket drops; the idle window passes; passivating() hands the session back to grain state; the DO hibernates — an idle conversation costs nothing, which is what makes per-user agents economically sane. One message days later re-activates the same identity. Resume code doesn't exist in the app.

And your "@State computed on the server" is the @RemoteState above — the island re-renders as typed partial generation streams in. One deliberate difference from LiveView: what crosses the wire is typed state, not DOM — islands render locally in WASM. I'll study LiveView's diffing protocol before freezing the wire format, as you suggest. Guided generation adds a nice bonus here: a @Generable result type that is also Codable flows model → actor → browser as a single typed value, checked at the model boundary and the actor boundary alike.

A question back

Composite identity spaces (the user × entity case above): concatenation works mechanically, but I suspect Akka Cluster Sharding's entity keys and Orleans' compound grain keys learned lessons about what not to do — key growth, hot prefixes, re-partitioning. If anything comes to mind when you're back, I'd love to hear it.

Thanks again — this was exactly the kind of feedback I was hoping for. Enjoy the holiday! :beach_with_umbrella:

1 Like

Thanks, Simon — coming from you that means a lot, given how far you've taken Elementary.

I am especially curious in how well the CSS lowering of your SwiftUI layer works, because I have tried a few times (for ElementaryUI) and could never really get the layout to work well enough (spacers, .frame, nested stacks....)

Our CSS approach is to compile sizing into intent markers rather than concrete CSS: a finite value lowers to real CSS plus a hug marker (swui-hug-h — "don't grow"), while .infinity lowers to a fill marker (swui-fill-h — "want to grow") and no CSS value at all. What an intent actually means is resolved by the stylesheet, per parent:

/* horizontal fill under a column parent: stretch the cross axis */
.swui-vstack > .swui-fill-h { align-self: stretch; width: 100%; }

/* under a row parent: grow the main axis — and defeat the min-content trap */
.swui-hstack > .swui-fill-h { flex: 1 1 0%; min-width: 0; }

Greed also propagates upward with :has(): a stack containing a Spacer consumes a fixed frame's proposed size, while one without keeps hugging and gets positioned by the frame's alignment — which is also how SwiftUI's centered-overflow behavior falls out for free. Because both the intents (fill/hug per axis, spacer) and the containers (stacks, ZStack, frame, scroll, grid) are closed sets, the whole intent × parent matrix is written out statically — there is no runtime measurement anywhere.

Whether that's "good enough" we verify by measurement rather than by claim: a conformance harness lays the same fixture tree out twice — in SwiftUI via NSHostingView (the oracle) and in the rendered CSS via WKWebView — and diffs the probed geometry. Current state: 32 fixtures / 71 probes matching within 1.5px, covering spacers (plain, minLength, multiple, centering), .frame (fixed / fill / bounded / min / alignment), nested stacks including SwiftUI's centered overflow, ZStack, Grid, and ScrollView clipping.

That said, I'd genuinely love to hear more about where it broke down for you — in as much detail as you're willing to share. Which layout shapes you tried, how the failures showed up, and where you concluded it couldn't be pushed further: that experience is exactly the map of the hard cases, and it's entirely possible we simply haven't encountered them yet. Whenever you find that play time, a deeper dive would be very welcome.

1 Like

Exciting work! I’ll be sure to give it a try.

1 Like

It's been a while and I did not really keep any records - but one thing I can remember causing trouble was placing two stacks with spacers inside another stack. eg: HStack { HStack { Spacer() }; HStack{ Spacer() } }.

also, another thing I remember: since things like backgound and padding must essentially create wrapping elements to compose correctly, I could not figure out to "propagate the greed" through all these layers...

1 Like

This is really really interesting work.

I haven't checkt the repo yet but I will give it a try because I have toyed with the idea of using distributed actors for this kind of frontend to backend communication for a while now but never got to actually working on it. Do you have any plans to bring the distributed actor system to other frontend platforms besides WASM?

1 Like

Thanks for digging these up, Simon — both are real, and both were divergences in our lowering until a couple of days ago. I turned each of your two cases into a fixture in a conformance harness that lays the same tree out twice — in SwiftUI via NSHostingView (the oracle) and in our rendered CSS via WKWebView — and diffs the geometry. Both of your snippets diverged. Both match to sub-pixel now, shipped in 0.6.3.

The root cause was exactly the "propagate the greed through all these layers" problem you named. We had a single marker class carrying two different kinds of greed — a Spacer's and a .frame(maxWidth: .infinity)'s — and let the stylesheet propagate it with :has(). But :has() can say "has a greedy descendant" and cannot say "has a greedy descendant with no sizing frame in between". So greed either died too early (your sibling-spacer rows, and greed vanishing inside a padding/background wrapper) or leaked straight through a bounding .frame().

The fix was to stop asking CSS to do the propagation. A stack's greed on each axis is now resolved at render time by walking its subtree — through nested stacks and through layout-neutral wrappers like padding/background, and terminating at a Frame (which absorbs the child's greed and proposes its own size, the way SwiftUI's frame does). Each greedy stack is marked directly, and the stylesheet consumes that mark only with direct parent > child rules — no :has() in the spacer path at all. So greed now travels through arbitrary nested stacks and arbitrary padding/background chains, and a sizing frame stops it — by construction rather than by enumeration.

Concretely: HStack { HStack { Spacer() }; HStack { Spacer() } } splits 50/50 now, the three-deep version does too, and a .padding().background()-wrapped fill carries through both wrappers. All checked against the SwiftUI oracle — 44 fixtures / 96 probes matching.

That said, the harness only covers the shapes we've thought to test — these two were exactly the kind of case we hadn't hit yet, and there are surely others we still haven't. So the offer stands, and I mean it: if you ever hit a layout that diverges from SwiftUI, please send it over, even just a snippet. Each one becomes a fixture, and cases from someone who's actually pushed this hard are the most valuable ones we can get.

Thanks — and that itch is exactly the bet the project is built on, so I hope you do give it a try.

Short answer: yes, this isn't WASM-specific. WASM is just one transport. The actor-system core (WebActorSystem, the invocation envelopes, virtual-actor id resolution) has no browser or WASM code in it at all — it's gated on a package trait, not os(WASI), and the wire format is plain JSON Codable envelopes. All the WASM/JavaScriptKit code lives in concrete transports behind a single-method protocol:

public protocol WebActorTransport: Sendable {
    func call(_ envelope: InvocationEnvelope) async throws -> ResponseEnvelope
}

So a native Swift client (iOS/macOS) has two ways in, neither touching the browser:

  • Request/response: POST the JSON envelope to /_swiftweb/actors/invoke with any HTTP stack (URLSession) and decode the ResponseEnvelope it returns.
  • Bidirectional, incl. server→client push: reuse the transport that ships today, WebSocketActorTransport — it's socket-agnostic. You hand it a sendFrame closure (a URLSessionWebSocketTask works) and feed inbound frames to receive(_:).

One honest boundary: what's portable is the actor / communication layer — calling your server's virtual actors in a type-safe way from another client. The UI layer (SwiftWebUI lowering to HTML/CSS) is web-specific, and that's not what moves. And there's no turnkey native-client SDK in the box yet — it would mean conforming that one protocol, or supplying that one closure. But the design deliberately keeps WASM as one transport among several, precisely so this stays open.

1 Like

I mean, clearly, the way this is implemented now is cost-prohibitive - that is the root problem in my opinion: there is no "clean enough" mapping onto CSS.


also, FYI, I have decided to stop responding here:

reading through these clearly LLM-generated forum posts sucks the joy out of my soul, and my tolerance for sifting through chatbot babble is very low.

I am happy to contribute further, but I will not do it through LLM walls of text. imho forums are for people, and I personally find these types of threads disrespectful.

12 Likes