HypenHypen
Server SDKs

Swift SDK

Complete guide to building Hypen servers with Swift

Swift SDK

The Swift SDK (HypenServer) provides a server-side Hypen implementation for Swift applications. It features a fluent chainable API for module definitions, typed action enums, Codable state with direct mutation, session management, and Remote UI streaming over WebSocket.

Installation

Note: Swift Package Manager requires Package.swift at the repository root. Since hypen-server-swift lives inside the hypen-engine-rs monorepo, you cannot reference it directly via the monorepo URL. Use one of these approaches:

Local dependency (monorepo)

// Package.swift
dependencies: [
    .package(path: "../hypen-engine-rs/hypen-server-swift"),
]

Separate published package (coming soon)

Note: The standalone hypen-server-swift package is not yet published. For now, use the local dependency approach above.

// Package.swift
dependencies: [
    .package(url: "https://github.com/hypen-lang/hypen-server-swift.git", from: "0.4.27"),
]

Then add to your target:

.target(
    name: "MyApp",
    dependencies: [
        .product(name: "HypenServer", package: "hypen-server-swift"),
    ]
)

Quick Start

Create a counter module with the fluent API and serve it over WebSocket:

import HypenServer

struct CounterState: Codable {
    var count: Int = 0
}

let counter = hypen(CounterState())
    .onAction("increment") { state in
        state.count += 1
    }
    .onAction("decrement") { state in
        state.count -= 1
    }
    .ui("""
        Column {
            Text("Count: ${state.count}")
            Button("@actions.increment") { Text("+") }
            Button("@actions.decrement") { Text("-") }
        }
    """)

let server = RemoteServer()
    .module("Counter", counter)
    .listen(3000)

The UI template uses Hypen DSL:

Column {
    Text("Count: ${state.count}")
    Button("@actions.increment") { Text("+") }
    Button("@actions.decrement") { Text("-") }
}

State is a Codable struct with var fields. Mutate directly in handlers — changes sync automatically.

Fluent API

The hypen() function returns a TypedModuleBuilder for fluent chaining. All configuration methods return self. Terminate with .build(), .ui(), or .uiFile().

let module = hypen(MyState())
    .name("MyModule")
    .persist()
    .version(2)
    .onCreated { state, context in /* ... */ }
    .onAction("doSomething") { state in /* ... */ }
    .onDestroyed { state, context in /* ... */ }
    .ui("Column { Text(\"Hello\") }")
Column {
    Text("Hello")
}

Terminal Methods

MethodReturnsDescription
.build()ModuleDefinitionFinalize the builder
.ui(template)ModuleDefinitionSet UI template and finalize
.uiFile(path)ModuleDefinitionLoad UI from file and finalize

Closure API (Alternative)

For complex modules, you can also use the closure-based builder:

let counter = hypen(CounterState()) { module in
    module.onAction("increment") { state in
        state.count += 1
    }
}

Typed Actions

Define actions as a CaseIterable enum for full type safety with exhaustive switch:

enum CounterAction: String, HypenAction, Codable, CaseIterable {
    case increment
    case decrement
    case reset
}

let counter = hypen(CounterState())
    .onAction(CounterAction.self) { state, action in
        switch action {
        case .increment: state.count += 1
        case .decrement: state.count -= 1
        case .reset: state.count = 0
        }
    }
    .ui("""
        Column {
            Text("Count: ${state.count}")
            Button("@actions.increment") { Text("+") }
            Button("@actions.reset") { Text("Reset") }
        }
    """)

The action enum conforms to String, HypenAction, Codable, and CaseIterable. Each case name maps directly to the action name dispatched from the UI:

Column {
    Text("Count: ${state.count}")
    Button("@actions.increment") { Text("+") }
    Button("@actions.reset") { Text("Reset") }
}

Three Levels of Type Safety

LevelAPIUse Case
String-based.onAction("name") { state in ... }Simple, quick prototyping
Typed payload.onAction("add", payload: AddPayload.self) { state, payload in ... }Actions with structured data
Typed action enum.onAction(MyAction.self) { state, action in switch action { ... } }Full type safety, exhaustive matching

Typed Payloads

Action payloads are automatically deserialized from JSON into Codable types:

struct AddPayload: Codable {
    let amount: Int
}

let counter = hypen(CounterState())
    .onAction("increment") { state in
        state.count += 1
    }
    .onAction("add", payload: AddPayload.self) { state, payload in
        state.count += payload.amount
    }
    .build()

When the client sends {"amount": 5} with the add action, the handler receives a fully typed AddPayload instance.

Async Action Handlers

Use onActionAsync for handlers that perform async work:

struct FetchState: Codable {
    var data: [String: String]? = nil
    var loading: Bool = false
}

let module = hypen(FetchState())
    .onActionAsync("fetchData") { state in
        state.loading = true
        // ... async work ...
        state.data = ["name": "Alice"]
        state.loading = false
    }
    .build()

Async variants are available for all action types:

.onActionAsync("name") { state in ... }
.onActionAsync("name", payload: P.self) { state, payload in ... }
.onActionAsync(MyAction.self) { state, action in ... }

State Management

Codable State

State is defined as a Codable struct with var fields:

struct AppState: Codable {
    var count: Int = 0
    var user: User? = nil
    var items: [String] = []
    var loading: Bool = false
}

In handlers, you mutate properties directly via inout:

.onAction("addItem", payload: AddItemPayload.self) { state, payload in
    state.items.append(payload.item)
    state.count = state.items.count
}

Changes are automatically diffed against the previous state and only modified keys are synced to the reactive engine.

ObservableState (Low-Level)

For advanced use cases, the underlying ObservableState provides path-based access:

let state = ObservableState(["count": 0, "user": ["name": "Alice"]])

// Read
state.get("count")           // 0
state.get("user.name")       // "Alice"
state.snapshot()             // full state copy

// Write (triggers change notifications)
state.set("count", 1)
state.set("user.name", "Bob")

// Replace entire state
state.replace(["count": 0, "user": ["name": "New"]])

// Listen for changes
state.onChange { changedKeys in
    print("Changed: \(changedKeys)")
}

Lifecycle Hooks

let counter = hypen(CounterState())
    .onCreated { state, context in
        print("Module created with count: \(state.count)")
    }
    .onAction("increment") { state in
        state.count += 1
    }
    .onDestroyed { state, context in
        print("Module destroyed at count: \(state.count)")
    }
    .build()
HookSignatureCalled When
onCreated(inout S, GlobalContext?) -> VoidModule instance is created
onDestroyed(inout S, GlobalContext?) -> VoidModule instance is destroyed

Error Handling

let module = hypen(AppState())
    .onAction("riskyAction") { state in
        state.count += 1
    }
    .onError { ctx in
        if let action = ctx.actionName {
            print("Action '\(action)' failed: \(ctx.error)")
        } else if let phase = ctx.lifecycle {
            print("Lifecycle '\(phase)' failed: \(ctx.error)")
        }
        return .handled  // suppress the error
        // or: .rethrown  // re-throw
        // or: nil         // default: log to stderr
    }
    .build()

ErrorContext Fields

FieldTypeDescription
errorErrorThe exception
stateObservableStateCurrent state
actionNameString?Set if error came from an action handler
lifecycleString?"created" or "destroyed" if from a lifecycle hook

Named Modules

Register modules by name using the HypenApp registry:

let app = HypenApp()

// Register via hypen() with name
let _ = hypen(CounterState(), name: "Counter", app: app)
    .onAction("increment") { state in state.count += 1 }
    .build()

// Or register manually
app.register("Settings", settingsDefinition)

// Query the registry
app.has("Counter")      // true
app.get("Counter")      // ModuleDefinition?
app.size                // 2

// Unregister
app.unregister("Settings")
app.clear()

Routing

HypenRouter

URL-based navigation with pattern matching:

let router = HypenRouter()

// Navigate
router.push("/users/123")
router.replace("/login")
router.back()

// Query current state
router.currentPath     // "/users/123"
router.state           // RouteState(currentPath, params, query, previousPath)

// Pattern matching
let match = router.matchPath("/users/:id", against: "/users/42")
// match?.params = ["id": "42"]

router.isActive("/users/:id")   // true

// Listeners
let unsub = router.onNavigate { from, to in
    print("\(from ?? "/")\(to)")
}
unsub()

Pattern Syntax

PatternExample MatchParams
/users/:id/users/42["id": "42"]
/posts/:postId/comments/:commentId/posts/1/comments/5["postId": "1", "commentId": "5"]
/files/*/files/a/b/cwildcard match
/about/aboutexact match

Managed Router

Automatically mount and unmount modules based on the current route:

let router = HypenRouter()
let app = HypenApp()
let ctx = HypenGlobalContext(router: router)

// Register modules
let _ = hypen(HomeState(), name: "HomePage", app: app).build()
let _ = hypen(CounterState(), name: "Counter", app: app)
    .onAction("increment") { state in state.count += 1 }
    .build()

// Route-driven lifecycle
let managed = ManagedRouter(router: router, registry: app, globalContext: ctx)
    .addRoute(RouteDefinition(path: "/", component: "HomePage"))
    .addRoute(RouteDefinition(path: "/counter", component: "Counter"))

managed.start()
router.push("/counter")  // auto-mounts Counter, unmounts HomePage

managed.activeModule      // current ModuleInstance?
managed.activeRoute       // current RouteDefinition?
managed.stop()

Event System

Type-safe pub/sub messaging:

let emitter = TypedEventEmitter()

// Define typed event keys
let userLoggedIn = EventKey<UserEvent>("user:login")
let notification = EventKey<String>("notification")

// Subscribe (type-safe)
let unsub = emitter.on(userLoggedIn) { event in
    print("\(event.email) logged in")
}

// Emit
emitter.emit(userLoggedIn, payload: UserEvent(email: "alice@example.com"))

// One-time listener
emitter.once(notification) { message in
    print("Notification: \(message)")
}

// Unsubscribe
unsub()

// Cleanup
emitter.removeAllListeners(userLoggedIn)
emitter.clearAll()

Built-in Framework Events

emitter.on(HypenEvents.moduleCreated) { event in
    print("Module created: \(event.moduleId)")
}

emitter.on(HypenEvents.routeChanged) { event in
    print("Route: \(event.from ?? "/") -> \(event.to)")
}

emitter.on(HypenEvents.stateUpdated) { event in
    print("State changed in \(event.moduleId): \(event.paths)")
}

emitter.on(HypenEvents.actionDispatched) { event in
    print("Action \(event.actionName) dispatched in \(event.moduleId)")
}

Global Context

Cross-module communication:

let ctx = HypenGlobalContext()

// Register modules
ctx.registerModule("counter", instance: counterInstance)
ctx.registerModule("auth", instance: authInstance)

// Access other modules
let counterRef = ctx.getModule("counter")!
counterRef.dispatchAction("increment")
let state = counterRef.getState()  // ["count": 1]

// Query
ctx.hasModule("counter")    // true
ctx.getModuleIds()           // ["counter", "auth"]
ctx.getGlobalState()         // combined state snapshot

// Events via global context
ctx.emit("custom:event", payload: ["data": "value"])
let unsub = ctx.on("custom:event") { payload in
    print("Event: \(payload)")
}
unsub()

Session Management

Session Lifecycle

let module = hypen(AppState())
    .onAction("increment") { state in
        state.count += 1
    }
    .onDisconnect { state, session in
        // Client disconnected, but session is alive (within TTL)
        print("Session \(session.id) disconnected, count was \(state.count)")
    }
    .onReconnect { session, restore in
        // Client reconnected with existing session ID
        print("Session \(session.id) reconnected")
        restore(["count": 42])
    }
    .onExpire { session in
        // Session TTL expired — no reconnection happened
        print("Session \(session.id) expired")
    }
    .build()

SessionManager

let sessionManager = SessionManager(config: SessionConfig(
    ttl: 300,  // 5 minutes
    concurrent: .kickOld
))

// Create a session
let session = sessionManager.createSession(props: ["userId": "user-1"])

// Suspend (on disconnect)
sessionManager.suspendSession(session.id, savedState: currentState) {
    print("Session \(session.id) expired")
}

// Resume (on reconnect)
if let saved = sessionManager.resumeSession(session.id) {
    // Restore saved state
}

// Stats
let stats = sessionManager.getStats()
// SessionStats(activeSessions, pendingSessions, totalConnections)

Concurrent Connection Policies

PolicyBehavior
.kickOld (default)New connection replaces existing on same session
.rejectNewRejects new connection if one already exists
.allowMultipleMultiple connections share the same session

Component Discovery

Discover .hypen templates from the filesystem:

let components = try discoverComponents("./components", options: DiscoveryOptions(
    patterns: [.folder, .sibling, .index],
    recursive: true
))

// Load into the component loader
loadDiscoveredComponents(components, into: componentLoader)

Discovery Patterns

PatternDirectory Structure
.folderCounter/component.hypen
.siblingCounter.hypen
.indexCounter/index.hypen

Component Watcher

Watch for component changes during development:

let watcher = ComponentWatcher(baseDir: "./components", options: WatchOptions(
    pollInterval: 1.0,
    onAdd: { print("Added: \($0.name)") },
    onRemove: { print("Removed: \($0)") },
    onUpdate: { print("Updated: \($0.name)") }
))
watcher.start()

Component Resolver

Resolve import statements from Hypen DSL:

let resolver = ComponentResolver(options: ResolverOptions(
    baseDir: "./components",
    app: myApp  // Check registry first
))

// Parse imports from DSL
let imports = parseImports("""
    import { Header, Footer } from "./shared"
    import MainContent from "./pages/main"
""")

// Resolve each import
for stmt in imports {
    let resolved = try resolver.resolve(stmt)
    // resolved["Header"] -> ResolvedComponent(module, template)
}

// Cache management
resolver.clearCache()

Native Engine Integration

The Swift server SDK includes native bindings to the Rust Hypen engine via UniFFI. This means the server doesn't just relay state — it actually parses Hypen DSL and produces the same patch format as all other Hypen SDKs.

When a client connects, RemoteServer creates a per-client NativeEngine instance that:

  1. Renders DSL → patches: Parses the UI template and produces create, setProp, setText, insert patches
  2. Handles state updates: When state changes, the engine diffs the render tree and produces incremental patches
  3. Dispatches actions: Routes @actions.* events from the UI to your Swift handlers

Building the Native Library

The Rust engine must be compiled before running the Swift server:

# Build the native library
cd hypen-engine-rs
cargo build --release --features uniffi

# Run Swift tests (Linux)
cd ../hypen-server-swift
LD_LIBRARY_PATH=../hypen-engine-rs/target/release swift test

# Run Swift tests (macOS)
cd ../hypen-server-swift
DYLD_LIBRARY_PATH=../hypen-engine-rs/target/release swift test

Or use the provided build script:

cd hypen-server-swift
./build-engine.sh

Regenerating Bindings

After making changes to the Rust engine's UniFFI interface:

./scripts/generate-bindings.sh

This regenerates both Kotlin and Swift bindings from the compiled library.

WebSocket Protocol

Same message protocol as all Hypen SDKs (Go, Kotlin, TypeScript, Rust). Each line is a separate JSON message (newline-delimited):

Server → Client:

{ "type": "initialTree", "module": "Counter", "state": { "count": 0 }, "patches": [...], "revision": 0 }
{ "type": "patch", "module": "Counter", "patches": [...], "revision": 1 }
{ "type": "stateUpdate", "module": "Counter", "state": { "count": 1 }, "revision": 1 }

Client → Server:

{ "type": "dispatchAction", "module": "Counter", "action": "increment", "payload": null }

The initialTree message now includes actual patches from the engine (not empty arrays). On state changes, the server sends both patch (incremental DOM operations) and stateUpdate (full state snapshot) messages.

Requirements

  • Swift 6.0+
  • macOS 13+ / iOS 16+
  • Rust 1.70+ (for building libhypen_engine)
  • WebSocketKit (SwiftNIO-based)

See Also

  • TypeScript SDK — TypeScript server SDK with sessions and component discovery
  • Kotlin SDK — Kotlin/JVM SDK with type-safe DSL and coroutine support
  • Go SDK — Go server SDK reference
  • Remote App Tutorial — Step-by-step guide to building a remote app
  • iOS Adapter — Client-side rendering for iOS/SwiftUI