Documentation
Description of image Version 1.0

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.

Swift 5.9+ iOS 15+ No dependencies MIT License
v1.0 Latest stable release — Swift Package Manager ready

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.

FeatureURLSessionAlamofirePulseKit
async/await
Combine publishersPartial
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.

PulseCore
Protocols, models, errors, configuration. Zero dependencies.
PulseNetworking
HTTP client, transport, retry engine, Combine bridge.
PulsePlugins
Logger, Auth, Retry, Cache, CircuitBreaker.
PulseStorage
Offline queue with NWPathMonitor and disk persistence.
PulseObservability
Event bus, latency percentiles, metrics collection.
PulseUI
SwiftUI debug panel. Use in #if DEBUG builds only.

Installation

Add PulseKit to your project using Swift Package Manager in under a minute.

Swift Package Manager

In Xcode

1

Open the Package Manager

In Xcode, choose File → Add Package Dependencies…

2

Enter the repository URL

// Paste this URL into the search field
https://github.com/PulseOfNetworking/PulseKit.git
3

Choose modules

Select the modules you need. For most apps, add PulseNetworking and PulsePlugins. Add PulseUI only to debug targets.

In Package.swift

Package.swift
let 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

PlatformMinimum Version
iOS15.0+
macOS12.0+
watchOS8.0+
tvOS15.0+
Swift5.9+
Xcode15.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.

Swift
import 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.

Swift
import 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.swift
import 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.swift
public 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

path
Relative path appended to the client's baseURL. Include a leading slash.String
method
HTTP method. .get, .post, .put, .patch, .delete, etc.HTTPMethod
queryParameters
URL query params. Keys are percent-encoded automatically. Default: empty.[String: String]
headers
Per-endpoint headers, merged with global config headers. Endpoint headers win on conflict.[String: String]
body
Request body. Use .json(), .formURLEncoded(), .multipart(), or .graphQL(). Default: .none.RequestBody
timeoutInterval
Request-level timeout in seconds. Overrides the client's default. Default: 30.TimeInterval
isQueueableWhenOffline
If true, failed requests due to no connection are added to the offline queue. Default: false.Bool

Request 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.

┌──────────────────────────────────────────────────────────────┐ │ plugins: [AuthPlugin, CachePlugin, LoggerPlugin, RetryPlugin] │ └──────────────────────────────────────────────────────────────┘ REQUEST FLOW → Auth → Cache → Logger → [Network] RESPONSE FLOW ← Auth ← Cache ← Logger ← [Network]

The PulsePlugin protocol

Swift
public 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.

Swift
struct 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

🔐
AuthPlugin
Injects Bearer tokens into every request. Automatically detects 401 responses and refreshes tokens transparently using an AsyncMutex to prevent token-refresh storms.
com.pulse.auth
📋
LoggerPlugin
Structured request/response logging with sensitive header redaction. Configurable verbosity from .minimal to .verbose. Never logs to production.
com.pulse.logger
🔁
RetryPlugin
Exponential backoff with full jitter to prevent thundering-herd problems. Custom shouldRetry closure for per-error decisions.
com.pulse.retry
💾
CachePlugin
TTL-based in-memory cache for GET responses. Actor-isolated for thread safety. Respects HTTP methods — never caches POST/PUT/DELETE.
com.pulse.cache
CircuitBreakerPlugin
Prevents cascade failures. Trips open after a configurable failure threshold. Allows one probe request after the cooldown period, then closes on success.
com.pulse.circuit-breaker

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:

Swift
struct 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

Swift
let 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

1

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.

2

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.

3

NWPathMonitor detects connectivity

The offline queue holds an NWPathMonitor that fires when the network path transitions from .unsatisfied to .satisfied.

4

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

Swift
PulseEventBus.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

Swift
struct 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

.idle
Initial state. No request has been made. Show a prompt or empty state.
.loading
A request is in-flight with no previous data. Show a full-screen skeleton or spinner.
.success(T)
Request succeeded. Associated value is the decoded response.
.refreshing(T)
Refreshing with previous data still available. Keep showing the old data with a subtle loading indicator.
.failure(PulseError)
Request failed. Show an error state with a retry button.

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

Swift
struct 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:

Swift
func 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

Swift
func 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

Swift
func 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:

Swift
let 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

Swift
let 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

Swift
let 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

Swift
PulseEventBus.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

Swift
let 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

request(_:)
Makes a typed request and returns the decoded response.async throws → E.Response
rawRequest(_:)
Returns the raw PulseResponse without decoding.async throws → PulseResponse
publisher(for:)
Returns a Combine publisher that emits once and completes.PulsePublisher<E.Response>
graphQL(query:variables:responseType:)
Sends a GraphQL query and decodes the data field.async throws → T
PulseClient.default
Shared client. Configure at app startup. Used by @APIRequest.static var PulseClient

PulseError

.noConnection
Device has no network connectivity. Retryable by the offline queue.
.timeout(URLRequest)
Request timed out. Retryable.
.unauthorized
401 response. Token refresh attempted; redirect to login if it fails.
.rateLimited(retryAfter:)
429 response. retryAfter contains the server's Retry-After header value in seconds.
.decodingFailed(type:underlying:)
Response could not be decoded. type is the Swift type name for debugging.
.sslPinningFailed(host:)
Certificate validation failed against pinned hashes. Do not retry.
isRetryable
Convenience property. Returns true for timeout, noConnection, 5xx, and rateLimited.Bool

Configuration

Customize PulseKit for your app's specific needs.

Swift
let 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.

Swift
do { 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.

Swift
class 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.

Swift
LoggerPlugin(options: .init( verbosity: .verbose, sensitiveHeaders: ["Authorization"], maxBodyLength: 4096, output: { OSLog.network.log($0) } ))

RetryPlugin

Exponential backoff with full jitter for distributed systems.

Swift
RetryPlugin( maxAttempts: 4, strategy: .exponentialWithJitter(base: 1.0, maxDelay: 30), shouldRetry: { error in error.isRetryable } )

CachePlugin

TTL-based in-memory response caching for GET requests.

Swift
CachePlugin(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)
CLOSED → (5 failures) → OPEN → (30s cooldown) → HALF-OPEN → (success) → CLOSED → (failure) → OPEN

WebSockets

Type-safe WebSocket connections via PulseSocket and TypedSocket.

Swift
struct 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.