Contents

sufiyanyusuf/sockit

A Swift WebSocket library with shared types between client and server, and a pure functional core.

Why Sockit?

πŸ”§ Raw pipe β€” URLSessionWebSocketTask has no typing, correlation, or timeouts. β†’ Sockit gives you end-to-end typed commands. Same DTOs on client and server.

πŸ§ͺ Untestable β€” WebSocket logic is coupled to the connection. β†’ All state logic is a plain function. Test it by calling it. No server needed.

πŸ”„ Reconnection β€” Backoff, heartbeats, channel re-joins β€” lots of edge cases. β†’ Built in, configurable, and tested. You don't write this code.

⚑ Concurrency β€” Swift 6 Sendable compliance is painful to retrofit. β†’ Actors throughout, zero warnings. Designed for strict concurrency from day one.

πŸ”‘ Auth headers β€” iOS drops them silently on WebSocket upgrade. β†’ Token sent as query param automatically. Server checks both.

Table of Contents

Key Concepts

How it works

+-------------------------------------------+
|  Public API (Actor)                       |
|  Client / Connection                      |
+-------------------------------------------+
                    |
                    v
+-------------------------------------------+
|  Reducer                                  |
|  (State, Action) -> [Effect]              |
|  All logic lives here. No side effects.   |
+-------------------------------------------+
                    |
                    v
+-------------------------------------------+
|  Transport                                |
|  URLSession (Apple) / NIO (Linux)         |
+-------------------------------------------+

When the client receives a message or the user takes an action, it goes to the reducer -- a plain function that takes the current state, returns what should happen next (as a list of effects). The actor layer then executes those effects (open a connection, send a message, emit an event).

Because the reducer is just a function, you can test every state transition by calling it directly:

var state = ClientState()
let effects = clientReducer(state: &state, action: .connect(config))

// Assert state changed
#expect(state.connection == .connecting(attempt: 1))
// Assert what effects were requested
#expect(effects.contains(.openConnection(url, token: "...")))

No mocks, no network, no async. Just input and output.

State machine

Connection and channel states are enums -- you can only be in one state at a time, and invalid transitions are caught at compile time:

enum ConnectionStatus {
    case disconnected
    case connecting(attempt: Int)
    case connected(since: Date)
    case reconnecting(attempt: Int, lastError: Error?)
}

enum ChannelState {
    case joining(joinRef: String)
    case joined(joinRef: String)
    case leaving
    case left
    case error(code: String, message: String)
}

Wire protocol

Simple JSON. The payload field IS your typed DTO -- no envelope wrapping, no intermediate types.

{
  "event": "profile.get",
  "payload": { "theme": "dark" },
  "requestId": "550e8400-e29b-41d4-a716-446655440000",
  "channel": "user:123",
  "status": "ok"
}

| Field | Required | Description | |-------------|----------|---------------------------------------| | event | Yes | Event name (e.g. "profile.get") | | payload | No | Typed DTO, encoded directly | | requestId | No | UUID for request/response correlation | | channel | No | Channel name for pub/sub | | status | No | "ok" or "error" (responses only) |

Error responses include an error object in the payload:

{
  "event": "chat.send",
  "requestId": "...",
  "status": "error",
  "payload": {
    "error": { "code": "not_authorized", "message": "Not a member of this channel" }
  }
}

Transport

On Apple platforms, SockitClient uses URLSessionWebSocketTask. On Linux, it auto-links a SwiftNIO-based transport via conditional dependency. Client() works on all platforms with no configuration.

You can also provide your own:

let client = Client(transportFactory: { MyCustomTransport() })

The TransportProtocol in SockitCore defines the contract.

Requirements

| Dependency | Version | |-----------|---------| | Swift | 6.0+ | | iOS | 17+ | | macOS | 14+ | | Linux | Swift 6.0+ toolchain | | Vapor | 4.99+ (server only) |

Installation

Add Sockit to your Package.swift:

dependencies: [
    .package(url: "https://github.com/sufiyanyusuf/sockit.git", from: "1.0.0"),
]

Then add the target you need:

// iOS/macOS client
.target(name: "MyApp", dependencies: [
    .product(name: "SockitClient", package: "sockit"),
])

// Vapor server
.target(name: "MyServer", dependencies: [
    .product(name: "SockitServer", package: "sockit"),
])

// Shared types only
.target(name: "SharedContracts", dependencies: [
    .product(name: "SockitCore", package: "sockit"),
])

Quick Start

Client

import SockitClient

let client = Client()

try await client.connect(config: ClientConfig(
    url: URL(string: "wss://api.example.com/socket")!,
    token: "auth-token"
))

// Typed command -- send and await a decoded response
struct GetProfile: SockitCommand {
    typealias Response = ProfileResponse
    static let event = Event.getProfile
}
let profile = try await client.send(GetProfile())

// Listen for events
for await message in client.messages {
    switch message {
    case .rawPushEvent(let push):
        switch push.event {
        case Event.chatMessage:
            let msg = try push.decodePayload(ChatMessage.self)
            displayMessage(msg)
        default:
            break
        }
    case .connectionStateChanged(let change):
        updateUI(for: change) // .connecting, .connected, .reconnecting(attempt:), .disconnected
    default:
        break
    }
}

Server (Vapor)

import Vapor
import SockitServer

struct GetProfileHandler: SockitHandlerNoPayload {
    typealias Response = ProfileResponse
    static let event = Event.getProfile

    func handle(context: HandlerContext) async throws -> Response {
        try await fetchProfile(userId: context.userId!)
    }
}

func routes(_ app: Application) async throws {
    let router = TypedRouter()
    await router.register(GetProfileHandler())

    app.sockit(path: "socket", router: router) { req in
        try await authenticateToken(req) // Returns UUID?
    }
}

Client API

Connecting

let client = Client()

// All parameters except url are optional
try await client.connect(config: ClientConfig(
    url: URL(string: "wss://api.example.com/socket")!,
    token: "jwt-token",                    // Sent as ?token= query param
    heartbeatInterval: 30.0,               // Default: 30s
    reconnectStrategy: .exponentialBackoff( // Default
        baseDelay: 1.0,
        maxDelay: 30.0,
        maxAttempts: 5
    ),
))

await client.disconnect()

Reconnection strategies:

.exponentialBackoff(baseDelay: 1.0, maxDelay: 30.0, maxAttempts: 5) // Default
.linear(delay: 2.0, maxAttempts: 10)
.none

Typed Commands (Preferred)

// Command with no payload
struct GetProfile: SockitCommand {
    typealias Response = ProfileResponse
    static let event = Event.getProfile
}
let profile = try await client.send(GetProfile())

// Command with payload
struct UpdateSettings: SockitCommandWithPayload {
    typealias Response = SettingsResponse
    static let event = Event.updateSettings

    let theme: String
    let notifications: Bool
}
let settings = try await client.send(UpdateSettings(theme: "dark", notifications: true))

// Command scoped to a channel
struct SendMessage: SockitCommandWithPayload {
    typealias Response = MessageResponse
    static let event = Event.sendMessage
    var channel: String? { Channel.general }

    let text: String
}

Channels

await client.join(Channel.general)
await client.join(Channel.general, payload: JoinParams(role: "member")) // With typed payload
await client.leave(Channel.general)

Listening for Messages

for await message in client.messages {
    switch message {
    case .connectionStateChanged(let change):
        // change: .connecting, .connected, .reconnecting(attempt:), .disconnected
        break
    case .channelStateChanged(let channel, let change):
        // channel: "room:general"
        // change: .joining, .joined, .leaving, .left, .error(code:, message:)
        break
    case .response(let response):
        // Response to a fire-and-forget SendableRequest
        break
    case .pushEvent(let push):
        // Server push event (fully decoded)
        break
    case .rawPushEvent(let push):
        // Server push event with deferred payload decoding
        let payload = try push.decodePayload(MyPayload.self)
        break
    case .requestTimeout(let requestId):
        // A fire-and-forget request timed out
        break
    }
}

Typed Push Events

Route server push events to typed handlers:

struct ChatMessageEvent: SockitPushEvent {
    typealias Payload = ChatMessage
    static let event = Event.chatMessage
}

let registry = PushEventRegistry()
await registry.on(ChatMessageEvent.self) { chatMessage in
    // chatMessage is already decoded as ChatMessage
    displayInChat(chatMessage)
}

// Route incoming push events through the registry
for await message in client.messages {
    if case .rawPushEvent(let push) = message {
        await registry.route(push)
    }
}

Raw Types (Deferred Decoding)

For high-throughput scenarios, decode only the events you need:

case .rawPushEvent(let push):
    switch push.event {
    case Event.chatMessage:
        let msg = try push.decodePayload(ChatMessage.self)
    case Event.presenceUpdate:
        let update = try push.decodePayload(PresenceUpdate.self)
    default:
        break // No JSON parsing cost for unhandled events
    }

| Type | Purpose | |----------------|--------------------------------------------------------| | RawPushEvent | Push event with payloadData: Data for deferred decode | | RawResponse | Response with dataPayload: Data for deferred decode | | RawPayload | Wrapper with decode<T>() method |

Server API

Handlers

// Handler with typed request payload
struct SendMessageHandler: SockitHandler {
    typealias Request = SendMessageRequest  // Decodable & Sendable
    typealias Response = SendMessageResponse // Encodable & Sendable
    static let event = Event.sendMessage

    func handle(request: Request, context: HandlerContext) async throws -> Response {
        let msg = try await saveMessage(from: context.userId!, text: request.text)
        return SendMessageResponse(id: msg.id, sentAt: msg.createdAt)
    }
}

// Handler with no request payload
struct GetTodayHandler: SockitHandlerNoPayload {
    typealias Response = TodaySnapshot
    static let event = Event.getToday

    func handle(context: HandlerContext) async throws -> Response {
        try await fetchTodaySnapshot(userId: context.userId!)
    }
}

HandlerContext provides:

  • connection: Connection -- the WebSocket connection actor
  • userId: UUID? -- from the authenticate closure

Routing

let router = TypedRouter()
await router.register(SendMessageHandler())
await router.register(GetTodayHandler())

app.sockit(path: "socket", router: router) { req in
    // Extract user ID from token -- supports both query param and header
    if let token = req.query[String.self, at: "token"] {
        return try await validateJWT(token)
    }
    guard let bearer = req.headers.bearerAuthorization else { return nil }
    return try await validateJWT(bearer.token)
}

Push Events (Server to Client)

app.connectionManager and app.channelRegistry are available anywhere in your Vapor app:

// Push to a specific connection (e.g. inside a handler)
try await context.connection.push(event: Event.chatMessage, payload: chatMessage, channel: Channel.general)
await context.connection.push(event: Event.typingStarted, channel: Channel.general) // No payload

// Push to a specific user (all their connections) -- from anywhere with access to app
try await app.connectionManager.sendToUser(userId, event: Event.notification, payload: notification)
await app.connectionManager.sendToUser(userId, event: Event.refresh)

// Push to all subscribers of a channel
let members = await app.channelRegistry.subscribers(for: Channel.general)
for connectionId in members {
    if let conn = await app.connectionManager.connection(for: connectionId) {
        try await conn.push(event: Event.chatMessage, payload: chatMessage, channel: Channel.general)
    }
}

// Broadcast to all connections
try await app.connectionManager.broadcast(event: Event.systemMaintenance, payload: maintenanceInfo)

Shared DTOs

Define request/response types in a shared module imported by both client and server:

// SharedContracts/Sources/Messages.swift
import Foundation

struct SendMessageRequest: Codable, Sendable {
    let text: String
    let channel: String
}

struct SendMessageResponse: Codable, Sendable {
    let id: UUID
    let sentAt: Date
}

The same types are used by SockitCommand on the client and SockitHandler on the server -- true end-to-end type safety with zero duplication.

Package Structure

| Module | Depends on | Description | |----------------------|------------------------|---------------------------------------------------| | SockitCore | Foundation | Shared types, wire protocol, transport abstraction | | SockitClient | SockitCore | WebSocket client (URLSession on Apple, NIO on Linux) | | SockitNIOTransport | SockitCore, WebSocketKit | NIO transport, auto-linked on Linux | | SockitServer | SockitCore, Vapor | Vapor WebSocket server with typed routing |

Sources/
  SockitCore/            -- Shared types, wire protocol, TransportProtocol
  SockitClient/          -- Client actor, reducer, URLSession transport
  SockitNIOTransport/    -- NIO transport (conditionally linked on Linux)
  SockitServer/          -- Connection, ConnectionManager, ChannelRegistry, TypedRouter

Testing

swift build    # Build all targets
swift test     # Run all 174 tests

Reducer tests are plain functions -- no server or connection needed:

@Test func connectFromDisconnected() {
    var state = ClientState()
    let effects = clientReducer(state: &state, action: .connect(config))

    #expect(state.connection == .connecting(attempt: 1))
    #expect(effects.contains(.openConnection(url, token: nil)))
}

Integration tests verify both transports against a real WebSocket server:

@Test func fullLifecycle() async throws {
    try await withEchoServer { port in
        let client = Client()
        try await client.connect(config: ClientConfig(
            url: URL(string: "ws://127.0.0.1:\(port)")!,
            reconnectStrategy: .none
        ))
        await client.send(SendableRequest(event: "echo"))
        await client.disconnect()
    }
}

Performance

Benchmarks on Apple Silicon:

| Operation | Throughput | |------------------|---------------| | Message creation | ~570K msgs/sec | | JSON encoding | ~46K msgs/sec | | JSON decoding | ~45K msgs/sec |

Use typed commands (SockitCommand) for single-parse decoding rather than double-parsing through intermediate types.

Contributing

See CONTRIBUTING.md for build instructions, test guidelines, and PR process.

License

MIT. See LICENSE.

Package Metadata

Repository: sufiyanyusuf/sockit

Default branch: main

README: README.md