Build smarter
iOS networking
PulseKit is a next-generation Swift networking framework with offline-first capabilities, plugin middleware, smart retries, and real-time observability. No dependencies. Pure URLSession.
Why PulseKit?
Most iOS networking libraries stop at HTTP. PulseKit is designed for the hard parts that come after — token refresh storms, offline resilience, cascade failures, distributed tracing, and end-to-end type safety.
| Feature | URLSession | Alamofire | PulseKit |
|---|---|---|---|
| async/await | ✓ | ✓ | ✓ |
| Combine publishers | — | Partial | ✓ |
| Plugin middleware | — | — | ✓ |
| Offline queue | — | — | ✓ |
| Circuit breaker | — | — | ✓ |
| Built-in metrics | — | — | ✓ |
| SwiftUI @APIRequest | — | — | ✓ |
Modules
Import only what you need. PulseKit is split into focused modules for lean integrations.
Installation
Add PulseKit to your project using Swift Package Manager in under a minute.
Swift Package Manager
In Xcode
Open the Package Manager
In Xcode, choose File → Add Package Dependencies…
Enter the repository URL
// Paste this URL into the search fieldhttps://github.com/PulseOfNetworking/PulseKit.git
Choose modules
Select the modules you need. For most apps, add PulseNetworking and PulsePlugins. Add PulseUI only to debug targets.
In Package.swift
Package.swiftlet package = Package( name: "MyApp", platforms: [.iOS(.v15)], dependencies: [ .package( url: "https://github.com/PulseOfNetworking/PulseKit.git", from: "1.0.0" ) ], targets: [ .target( name: "MyApp", dependencies: [ .product(name: "PulseNetworking", package: "PulseKit"), .product(name: "PulsePlugins", package: "PulseKit"), ] ) ] )
Requirements
| Platform | Minimum Version |
|---|---|
| iOS | 15.0+ |
| macOS | 12.0+ |
| watchOS | 8.0+ |
| tvOS | 15.0+ |
| Swift | 5.9+ |
| Xcode | 15.0+ |
No third-party dependencies
PulseKit uses only Apple frameworks: URLSession, Combine, Network, CryptoKit, and SwiftUI. Your app's dependency graph stays clean.
Quick Start
Make your first network request with PulseKit in three steps.
1. Define an endpoint
An Endpoint is a value type that declaratively describes a single API request. It has no side effects — just data.
Swiftimport PulseCore struct GetUsersEndpoint: Endpoint { typealias Response = [User] // Decoded automatically var path: String { "/users" } var method: HTTPMethod { .get } var queryParameters: [String: String] { ["page": String(page)] } let page: Int }
2. Create a client
Configure once at app startup, then share via dependency injection or PulseClient.default.
Swiftimport PulseNetworking import PulsePlugins let config = PulseConfiguration( baseURL: URL(string: "https://api.example.com/v1")! ) let client = PulseClient( configuration: config, plugins: [ AuthPlugin(tokenProvider: myTokenStore), LoggerPlugin(), RetryPlugin(maxAttempts: 3) ] )
3. Make a request
PulseKit supports async/await, Combine, and the @APIRequest SwiftUI property wrapper.
Swift — async/await// async/await let users = try await client.request(GetUsersEndpoint(page: 1)) // Combine publisher client.publisher(for: GetUsersEndpoint(page: 1)) .receive(on: DispatchQueue.main) .assign(to: \.users, on: viewModel) .store(in: &cancellables) // SwiftUI @APIRequest @APIRequest(GetUsersEndpoint(page: 1), autoFetch: true) var users: RequestPhase<[User]>
Tip — Share one client per domain
Create one PulseClient per API domain and share it app-wide. Each client maintains its own plugin chain, retry state, and offline queue. Having multiple clients for the same base URL wastes resources.
Complete example
SwiftContentView.swiftimport SwiftUI import PulseCore import PulseNetworking struct User: Codable, Identifiable { let id: Int let name: String let email: String } struct GetUsersEndpoint: Endpoint { typealias Response = [User] var path: String { "/users" } var method: HTTPMethod { .get } } struct UsersView: View { @APIRequest(GetUsersEndpoint(), autoFetch: true) var users: RequestPhase<[User]> var body: some View { switch users { case .loading: ProgressView() case .success(let list): List(list) { Text($0.name) } case .failure(let error): Text(error.localizedDescription ?? "Error") default: EmptyView() } } }
Endpoints
The Endpoint protocol is the backbone of PulseKit's declarative API layer. Every network request is a value type.
The Endpoint protocol
SwiftPulseCore/Endpoint.swiftpublic protocol Endpoint: Sendable { associatedtype Response: Decodable var path: String { get } var method: HTTPMethod { get } var queryParameters: [String: String] { get } var headers: [String: String] { get } var body: RequestBody { get } var timeoutInterval: TimeInterval { get } var isQueueableWhenOffline: Bool { get } }
Properties
baseURL. Include a leading slash.String.get, .post, .put, .patch, .delete, etc.HTTPMethod.json(), .formURLEncoded(), .multipart(), or .graphQL(). Default: .none.RequestBody30.TimeIntervaltrue, failed requests due to no connection are added to the offline queue. Default: false.BoolRequest body types
Swift// JSON body — sets Content-Type: application/json automatically var body: RequestBody { .json(CreateUserRequest(name: name)) } // Form URL-encoded — sets Content-Type: application/x-www-form-urlencoded var body: RequestBody { .formURLEncoded(["username": username]) } // Multipart upload — for file and image uploads var body: RequestBody { let part = MultipartFormData.Part( name: "avatar", data: imageData, filename: "avatar.jpg", mimeType: "image/jpeg" ) return .multipart(MultipartFormData(parts: [part])) } // GraphQL — wraps in {"query": ..., "variables": ...} var body: RequestBody { .graphQL(query: "query { users { id name } }", variables: nil) }
Practical examples
Swift// GET with query parameters struct SearchUsersEndpoint: Endpoint { typealias Response = [User] var path: String { "/users/search" } var method: HTTPMethod { .get } var queryParameters: [String: String] { ["q": query, "page": String(page)] } let query: String; let page: Int } // POST with JSON body struct CreateUserEndpoint: Endpoint { typealias Response = User struct Body: Encodable, Sendable { let name: String; let email: String } var path: String { "/users" } var method: HTTPMethod { .post } var body: RequestBody { .json(Body(name: name, email: email)) } let name: String; let email: String } // DELETE with no response body struct DeleteUserEndpoint: Endpoint { typealias Response = EmptyResponse var path: String { "/users/\(id)" } var method: HTTPMethod { .delete } let id: Int }
Plugin System
Plugins are composable middleware that intercept every request and response. They are PulseKit's most powerful extensibility mechanism.
How it works
Plugins form a symmetric pipeline. They execute in order during request preparation and in reverse order during response handling — exactly like Express.js middleware or an onion model.
The PulsePlugin protocol
Swiftpublic protocol PulsePlugin: Sendable { var identifier: String { get } /// Modify the request before it's sent. /// Throw a PulseError to abort the entire request. func prepare(_ request: inout URLRequest, for context: PluginContext) async throws /// Called just before the request is dispatched. Great for logging. func willSend(_ request: URLRequest, for context: PluginContext) async /// Intercept the response. Return nil to pass through unchanged. func didReceive(_ result: Result<PulseResponse, PulseError>, for context: PluginContext) async -> Result<PulseResponse, PulseError>? /// Called when the full cycle is done. Good for cleanup. func didComplete(_ context: PluginContext) async }
PluginContext
A thread-safe context object passed through the entire plugin lifecycle. Use it to share data between prepare and didReceive in the same request — for example, storing a start time for latency measurement.
Swiftstruct TimingPlugin: PulsePlugin { let identifier = "com.myapp.timing" func prepare(_ request: inout URLRequest, for context: PluginContext) async throws { context.store["start"] = Date() } func didReceive(_ result: Result<PulseResponse, PulseError>, for context: PluginContext) async -> Result<PulseResponse, PulseError>? { let elapsed = Date().timeIntervalSince( context.store["start"] as! Date ) print("⏱ \(context.endpointPath): \(elapsed * 1000)ms") return nil // pass through unchanged } }
Built-in plugins
Plugin order matters
Always place AuthPlugin first so it can inject tokens before other plugins see the request. Place LoggerPlugin last in the prepare chain so it logs the fully-modified request.
Offline-First
PulseKit can queue failed requests when the device has no connectivity and replay them automatically when the network returns — with zero extra code at the call site.
Enabling the offline queue
First, opt an endpoint in with a single property:
Swiftstruct SyncProgressEndpoint: Endpoint { typealias Response = EmptyResponse var path: String { "/progress/sync" } var method: HTTPMethod { .post } var body: RequestBody { .json(payload) } // ↓ This single property enables offline queueing var isQueueableWhenOffline: Bool { true } let payload: ProgressPayload }
Configure queue policy
Swiftlet config = PulseConfiguration(baseURL: url) .with(\.offlineQueuePolicy, value: OfflineQueuePolicy( isEnabled: true, maxQueueSize: 100, // Max requests held in memory + disk persistsToDisk: true, // Survives app restarts requestExpiry: 86400 // Expire after 24 hours ))
How it works
Request fails with noConnection
When PulseKit receives a PulseError.noConnection for an endpoint where isQueueableWhenOffline == true, it serializes the full URLRequest and adds it to the queue.
Persisted to disk atomically
The queue is written to a JSONL file using an atomic rename (write to .tmp, then rename). This prevents corruption if the app is killed during a write.
NWPathMonitor detects connectivity
The offline queue holds an NWPathMonitor that fires when the network path transitions from .unsatisfied to .satisfied.
Queue drains automatically
All non-expired queued requests are replayed in order through the full plugin pipeline. Expired requests (past their TTL) are pruned silently.
Never queue financial transactions
Set offlineQueuePolicy: .disabled for payment or banking clients. Replaying payment requests when connectivity returns can cause duplicate charges. Use idempotency keys and server-side deduplication instead.
Observing queue events
SwiftPulseEventBus.shared.events .sink { event in switch event { case .offlineQueueDraining(let count): print("📡 Replaying \(count) queued requests") case .offlineQueueRequestCompleted(let id, let success): print("Request \(id): \(success ? "✓" : "✗")") default: break } } .store(in: &cancellables)
SwiftUI Integration
The @APIRequest property wrapper collapses the entire async request lifecycle into a single line of view code.
@APIRequest
Swiftstruct MoviesView: View { @APIRequest(GetMoviesEndpoint(), autoFetch: true) var movies: RequestPhase<[Movie]> var body: some View { Group { switch movies { case .idle: Button("Load") { $movies.fetch() } case .loading: ProgressView() case .success(let list): MovieList(movies: list) .refreshable { $movies.refresh() } case .refreshing(let list): // Still show old data while new data loads MovieList(movies: list) .overlay(ProgressView()) case .failure(let error): ErrorView(error: error) { $movies.fetch() // Retry } } } } }
RequestPhase
Environment injection
Inject different clients for testing and previews using the SwiftUI environment:
Swift// In your app or scene ContentView() .environment(\.pulseClient, productionClient) // In Xcode Previews struct MoviesView_Previews: PreviewProvider { static var previews: some View { MoviesView() .environment(\.pulseClient, .preview()) } }
Pagination with @PaginatedRequest
Swiftstruct FeedView: View { @PaginatedRequest(GetPostsEndpoint(page: 1)) var feed: PaginatedPhase<[Post]> var body: some View { List { ForEach(feed.items ?? []) { post in PostRow(post: post) } // Infinite scroll trigger if case .loaded(_, hasMore: true) = feed { ProgressView() .onAppear { $feed.loadMore() } } } .refreshable { $feed.refresh() } } }
Testing Guide
PulseKit is designed with testing as a first-class concern. Every component is protocol-based and injectable, so you never need to hit a real server in tests.
MockTransport
Swap URLSessionTransport with MockTransport to provide any response your test needs:
Swiftfunc test_request_decodesSuccessfully() async throws { let user = User(id: 1, name: "Alice", email: "alice@example.com") let transport = MockTransport { request in let data = try JSONEncoder().encode(user) return PulseResponse( data: data, httpResponse: .mock(statusCode: 200), request: request, latency: 0.01 ) } let client = PulseClient( configuration: PulseConfiguration(baseURL: url), transport: transport ) let result: User = try await client.request(GetUserEndpoint(id: 1)) XCTAssertEqual(result.name, "Alice") }
Testing error paths
Swiftfunc test_request_throws401AsUnauthorized() async throws { let transport = MockTransport { request in PulseResponse( data: Data(), httpResponse: .mock(statusCode: 401), request: request, latency: 0.01 ) } let client = PulseClient(configuration: config, transport: transport) do { let _: User = try await client.request(GetUserEndpoint(id: 1)) XCTFail("Expected .unauthorized") } catch PulseError.unauthorized { // Expected ✓ } }
Testing retry behavior
Swiftfunc test_retry_succeedsOnThirdAttempt() async throws { var callCount = 0 let transport = MockTransport { request in callCount += 1 if callCount < 3 { return PulseResponse(data: Data(), httpResponse: .mock(statusCode: 503), request: request, latency: 0.01) } let data = try JSONEncoder().encode(User(id: 1, name: "Alice")) return PulseResponse(data: data, httpResponse: .mock(statusCode: 200), request: request, latency: 0.01) } let retryConfig = config.with(\.retryPolicy, value: RetryPolicy( maxAttempts: 3, backoffStrategy: .immediate, // No delay in tests! retryableStatusCodes: [503], retryOnTimeout: false, retryOnNoConnection: false )) let client = PulseClient(configuration: retryConfig, transport: transport) let result: User = try await client.request(GetUserEndpoint(id: 1)) XCTAssertEqual(callCount, 3) XCTAssertEqual(result.name, "Alice") }
Use .immediate backoff in tests
Always set backoffStrategy: .immediate in test configurations. Exponential backoff causes tests to run slowly and timeouts to become flaky. Save real delay strategies for production configs.
Security
PulseKit provides multiple security layers suitable for fintech, healthcare, and enterprise applications.
SSL Certificate Pinning
Pin against public key hashes (recommended) or full certificate data:
Swiftlet config = PulseConfiguration(baseURL: apiURL) .with(\.sslPinningPolicy, value: .publicKeyHashes([ "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", // Primary "sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=" // Backup ← always pin two! ]))
Always pin two certificates
Pin your current certificate and your backup/next certificate. If you only pin one and it expires, your app will be unable to connect until users update. Certificate rotation without a forced update requires a backup pin already deployed.
HMAC Request Signing
Swiftlet signingPlugin = RequestSigningPlugin( secret: ProcessInfo.processInfo.environment["API_SIGNING_SECRET"]!, signatureHeader: "X-Signature-256" ) // Each request gets: X-Pulse-Timestamp + X-Signature-256 // Signature = HMAC-SHA256(METHOD|PATH|TIMESTAMP|BODY_HASH)
Sensitive header redaction
Swiftlet logger = LoggerPlugin(options: .init( verbosity: .standard, sensitiveHeaders: [ "Authorization", "X-API-Key", "Cookie", "X-Signature-256" ] )) // These headers appear as "***REDACTED***" in logs
Observability
PulseKit emits structured events for every request lifecycle, and collects latency percentiles, error rates, and per-endpoint statistics automatically.
Event Bus
SwiftPulseEventBus.shared.events .sink { event in switch event { case .requestCompleted(let result, let context, let latency): analytics.track(latency: latency, path: context.endpointPath) case .circuitBreakerOpened(let endpoint): alerting.fire(.circuitBreakerOpen, endpoint: endpoint) case .requestRetrying(let context, let error, let attempt): print("↩ Retry \(attempt) for \(context.endpointPath): \(error)") default: break } } .store(in: &cancellables)
Metrics collection
Swiftlet metrics = PulseMetricsCollector() metrics.attach(to: PulseEventBus.shared) // At any point, take a snapshot let snapshot = await metrics.snapshot() print("Success rate: \(snapshot.successRate * 100)%") print("p50 latency: \(snapshot.p50Latency * 1000)ms") print("p99 latency: \(snapshot.p99Latency * 1000)ms")
SwiftUI debug panel
Swift#if DEBUG import PulseUI #endif struct SettingsView: View { @State private var showDebugger = false var body: some View { List { #if DEBUG Button("Network Inspector") { showDebugger = true } .sheet(isPresented: $showDebugger) { NavigationView { PulseDebugView() } } #endif } } }
Only import PulseUI in debug builds
Wrap all import PulseUI statements in #if DEBUG. The debug panel keeps a rolling log of the last 500 requests in memory, which is unnecessary overhead in production builds.
API Reference
Quick reference for the most commonly used types and methods in PulseKit.
PulseClient
PulseResponse without decoding.async throws → PulseResponsedata field.async throws → T@APIRequest.static var PulseClientPulseError
retryAfter contains the server's Retry-After header value in seconds.type is the Swift type name for debugging.true for timeout, noConnection, 5xx, and rateLimited.BoolConfiguration
Customize PulseKit for your app's specific needs.
Swiftlet config = PulseConfiguration(baseURL: url) .with(\.timeout, value: 20) .with(\.retryPolicy, value: .default) .with(\.logLevel, value: .info) .with(\.defaultHeaders, value: [ "X-App-Version": appVersion, "X-Platform": "iOS" ])
PulseClient
The main entry point into PulseKit.
See Quick Start
Full PulseClient documentation is covered in the Quick Start and API Reference sections.
Error Handling
PulseKit uses a typed error hierarchy for clean error handling at call sites.
Swiftdo { let user: User = try await client.request(GetUserEndpoint(id: 1)) } catch let error as PulseError { switch error { case .noConnection: showOfflineBanner() case .unauthorized: navigateToLogin() case .rateLimited(let retryAfter): showRetryBanner(delay: retryAfter) default: showGenericError(error.localizedDescription) } }
Concurrency
PulseKit is built for Swift's structured concurrency model.
Swift// Structured concurrency — automatic cancellation async let users = client.request(GetUsersEndpoint()) async let settings = client.request(GetSettingsEndpoint()) let (u, s) = try await (users, settings) // Task cancellation propagates into PulseKit automatically let task = Task { try await client.request(LongOperationEndpoint()) } task.cancel() // URLSessionTask is also cancelled
AuthPlugin
Transparent token injection and refresh handling.
Swiftclass MyTokenStore: TokenProvider { func accessToken() async -> String? { Keychain.accessToken } func refreshToken() async throws -> String { /* call refresh endpoint */ } func handleAuthenticationFailure() async { /* show login screen */ } } AuthPlugin(tokenProvider: MyTokenStore())
LoggerPlugin
Structured, configurable request/response logging.
SwiftLoggerPlugin(options: .init( verbosity: .verbose, sensitiveHeaders: ["Authorization"], maxBodyLength: 4096, output: { OSLog.network.log($0) } ))
RetryPlugin
Exponential backoff with full jitter for distributed systems.
SwiftRetryPlugin( maxAttempts: 4, strategy: .exponentialWithJitter(base: 1.0, maxDelay: 30), shouldRetry: { error in error.isRetryable } )
CachePlugin
TTL-based in-memory response caching for GET requests.
SwiftCachePlugin(ttl: 300, maxMemoryItems: 200) // 5-minute TTL // Manual invalidation await cachePlugin.invalidate(url: URL(string: "/users")!) await cachePlugin.clearAll()
CircuitBreakerPlugin
Prevents cascade failures in distributed systems.
Swift// Opens after 5 failures, 30-second cooldown CircuitBreakerPlugin(failureThreshold: 5, cooldownDuration: 30)
WebSockets
Type-safe WebSocket connections via PulseSocket and TypedSocket.
Swiftstruct LiveScoresSocket: SocketEndpoint { var path: String { "/ws/scores" } var pingInterval: TimeInterval { 30 } } let socket = TypedSocket<ScoreUpdate>(endpoint: LiveScoresSocket()) await socket.connect() for await update in socket.messages { updateScoreboard(update) }
Migration Guide
Migrating from URLSession or Alamofire to PulseKit.
Migration guide coming soon
Step-by-step migration instructions for URLSession and Alamofire integrations will be published with v1.1. In the meantime, see the Quick Start guide.