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!

9 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