A lightweight, actor-based, protocol-driven networking layer for iOS — HTTP and WebSocket.
- iOS 17+
- Swift 6.1
- No external dependencies
Add unPiNetworking to your Package.swift:
dependencies: [
.package(url: "https://github.com/unPi-ro/unPiNetworking.git", from: "1.0.0")
]Then add it to your target:
.target(
name: "YourApp",
dependencies: ["unPiNetworking"]
)import unPiNetworking
let configuration = NetworkConfiguration(
defaultHeaders: ["Authorization": "Bearer token"],
timeoutInterval: 30,
retryCount: 3,
cachePolicy: .useProtocolCachePolicy,
defaultContentType: "application/json"
)
let service = NetworkService(configuration: configuration)All configuration parameters have defaults, so the simplest setup is:
let service = NetworkService(configuration: NetworkConfiguration())struct User: Decodable, Sendable {
let id: Int
let name: String
}
let endpoint = Endpoint(
baseURL: URL(string: "https://api.example.com")!,
path: "/users/1"
)
let user: User = try await service.request(endpoint)let endpoint = Endpoint(
baseURL: URL(string: "https://api.example.com")!,
path: "/users",
queryItems: [URLQueryItem(name: "page", value: "1")]
)
let users: [User] = try await service.request(endpoint)struct CreateUser: Encodable, Sendable {
let name: String
let email: String
}
let endpoint = Endpoint(
baseURL: URL(string: "https://api.example.com")!,
path: "/users",
method: .post
)
let newUser = CreateUser(name: "Ada", email: "ada@example.com")
let created: User = try await service.request(endpoint, body: newUser)NetworkService throws NetworkError for URL, response, and HTTP status failures. Other errors (decoding, URL session) are rethrown as-is.
do {
let user: User = try await service.request(endpoint)
} catch let error as NetworkError {
switch error {
case .invalidURL:
print("Invalid URL")
case .invalidResponse:
print("Invalid response")
case .httpError(let statusCode, let data):
print("HTTP \(statusCode): \(String(data: data, encoding: .utf8) ?? "")")
default:
print("Network error: \(error)")
}
} catch is DecodingError {
print("Failed to decode response")
} catch let error as URLError {
print("URL error: \(error.code)")
} catch {
print("Unexpected error: \(error)")
}Endpoint headers are merged with configuration defaults. Endpoint headers take priority on conflicts.
let endpoint = Endpoint(
baseURL: URL(string: "https://api.example.com")!,
path: "/users",
headers: ["X-Custom-Header": "value"]
)NetworkService automatically retries requests that fail with transient URLError codes (timeout, connection lost, not connected to internet). The retry count is configured via NetworkConfiguration.retryCount (default: 3).
let config = WebSocketConfiguration(
defaultHeaders: ["Authorization": "Bearer token"],
timeoutInterval: 30, // Handshake timeout
retryCount: 3, // Auto-reconnect attempts
retryBaseDelay: 1.0, // Initial backoff delay (doubles each retry)
retryMaxDelay: 30.0, // Maximum backoff delay
pingInterval: 30.0, // Keep-alive ping interval (nil to disable)
maximumMessageSize: 1_048_576 // 1 MB max message size
)
let service = WebSocketService(configuration: config)All parameters have defaults, so the simplest setup is:
let service = WebSocketService()// Connect with optional per-connection headers
try await service.connect(
url: URL(string: "wss://example.com/ws")!,
headers: ["X-Room-Id": "123"]
)
// Disconnect (defaults to .normalClosure)
await service.disconnect()
// Disconnect with a specific close code
await service.disconnect(closeCode: .goingAway, reason: nil)Header merging works the same as HTTP — config defaults + connect-time headers, connect-time wins on conflicts.
struct ChatMessage: Codable, Sendable {
let text: String
let sender: String
}
try await service.send(ChatMessage(text: "Hello!", sender: "me"))receive returns a typed AsyncThrowingStream — iterate with for try await:
struct ServerMessage: Codable, Sendable {
let text: String
let sender: String
let timestamp: String
}
let stream = await service.receive(ServerMessage.self)
for try await message in stream {
print("\(message.sender): \(message.text)")
}Multiple consumers can call receive — each gets all messages (fan-out).
Observe connection state changes via AsyncStream:
for await state in service.connectionState {
switch state {
case .disconnected:
print("Disconnected")
case .connecting:
print("Connecting...")
case .connected:
print("Connected")
case .reconnecting(let attempt, let maxAttempts):
print("Reconnecting \(attempt)/\(maxAttempts)...")
}
}Or check the current state directly:
let state = await service.currentStateOn unexpected disconnection, WebSocketService automatically reconnects with exponential backoff plus jitter. After exhausting all retry attempts, the state becomes .disconnected. Intentional disconnects (calling disconnect()) do not trigger reconnection.
WebSocket operations throw WebSocketError:
do {
try await service.send(message)
} catch let error as WebSocketError {
switch error {
case .notConnected:
print("Not connected")
case .encodingError(let error):
print("Failed to encode: \(error)")
case .sendFailed(let error):
print("Send failed: \(error)")
case .maxReconnectAttemptsExhausted:
print("Gave up reconnecting")
default:
print("WebSocket error: \(error)")
}
}A demo iOS app and a Python echo server are included in Examples/:
# Terminal 1 — start the echo server
pip3 install websockets
python3 Examples/echo_server.py
# Xcode — open and run the demo app on Simulator
open Examples/WebSocketDemo/WebSocketDemo.xcodeprojgit clone https://github.com/unPi-ro/unPiNetworking.git
cd unPiNetworking
./bootstrap.sh # Install pre-push git hook
swift build # Build
swift test # Run all tests
swiftlint lint --strict # LintMIT