MTE Relay Client for iOS using Swift
Current SDK release: 5.3.0.
See Migrating from v4 for what changed and how to update your code.
Introduction
This Swift Package provides the Eclypses MTE Relay Client for iOS. It enables secure, encrypted HTTP(S) communication between your iOS app and your backend via an MTE Relay server. Requests you already build with URLRequest are transparently encrypted with MTE (including Kyber-1024 post-quantum key exchange) and relayed to the server, which decrypts them and forwards them to your real backend.
Purpose of the Relay Client:
- Securely relay ordinary HTTP requests to your backend
- Protect sensitive headers and bodies with MTE encryption
- Stream large files efficiently without loading them into memory
- Drop-in shaped: build the
URLRequestyou already build, receive(Data, URLResponse)as you already do
:::tip Comprehensive Documentation This guide provides a quick-start for experienced developers. For detailed examples, streaming/upload APIs, troubleshooting, and in-depth explanations, see the complete documentation on GitHub. :::
Prerequisites
- iOS 16.0 or later
- Swift 5.7 or later
- Xcode 14.0 or later
- MTE core 4.2.1 (via
mte-client-ios) — resolved automatically as a Swift Package dependency; no separate download required - Access to a licensed MTE Relay server instance (the server URL, not your backend URL, is what you point requests at). The MTE licence is embedded in this package — there is nothing for you to supply
- A code-signed app — Relay keeps a small host record in the keychain. Ad-hoc signing is enough and no entitlements file is needed. A generated or CI project that disables signing builds fine and then fails on the first request; set
CODE_SIGNING_ALLOWED: YESandCODE_SIGN_IDENTITY: "-"
Installation
Swift Package Manager (Recommended)
- In Xcode, go to File > Add Package Dependencies...
- Enter the package URL:
https://github.com/Eclypses/mte-relay-client-ios.git - Choose the Up to Next Major Version rule starting from
5.3.0and add the Relay product to your target.
If you received the package as a local drop rather than from GitHub, declare it by path instead — both halves are required:
# XcodeGen project.yml. Paths are relative to this file.packages:Relay: # your key, anything you likepath: ../mte-relay-client-iostargets:YourApp:dependencies:- package: Relay # the key above, not the package identityproduct: Relay// Package.swiftdependencies: [ .package(path: "../mte-relay-client-ios") ]
The Mte dependency (from mte-client-ios) is resolved transitively — no extra setup.
Setup
- Import the module:
import Relay
- Create a
Relayinstance.init()isasync throws: it validates the MTE license; pairing with the server happens automatically on the first request.
import Foundation
import Relay
final class YourNetworkManager {
// Not passed to Relay() — Relay derives the host it pairs with from each
// URLRequest, so the relay server URL belongs in the requests you build.
private let relayServerUrl = URL(string: "https://your-relay-server.com")!
private var relay: Relay?
// Relay() is async throws, so create it on first use. The async is absorbed
// by a function that was already async, leaving your views unchanged.
private func relayInstance() async throws -> Relay {
if let relay { return relay }
let relay = try await Relay()
self.relay = relay
return relay
}
func send(_ request: URLRequest) async throws -> (Data, URLResponse) {
try await relayInstance().dataTask(with: request)
}
}
Caching it like this needs a reference type — a struct cannot mutate stored
state from a non-mutating async method. If your network layer is a struct today,
make it a final class.
Reuse one Relay; it holds the pairs established with each host. Creating it in
an initializer instead makes that initializer async throws and pushes the same
requirement onto everything that constructs it — usually an optional @State
plus .task in SwiftUI. Creating it on first use avoids that entirely.
Usage
The request methods are asynchronous and throw — use try await and a do/catch (for try await over request(with:)'s stream). The examples call relayInstance(), the accessor from Setup, because the stored relay is optional until first use.
Important: point your
URLRequestat your MTE Relay server URL, not your backend API URL. The relay server decrypts the request and forwards it to your actual backend.
GET Request
var request = URLRequest(url: URL(string: "https://your-relay-server.com/api/users/123")!)
request.httpMethod = "GET"
request.addValue("application/json", forHTTPHeaderField: "Accept")
request.addValue("Bearer abc123", forHTTPHeaderField: "Authorization")
// Every header on the request is encrypted by default — there is nothing to pass here.
// `unencryptedHeaders` names the exceptions, the few that must stay readable on the hop
// to the relay; see the parameter list below.
do {
let (data, response) = try await relayInstance().dataTask(with: request)
if let httpResponse = response as? HTTPURLResponse {
guard (200...299).contains(httpResponse.statusCode) else { return }
}
let json = try JSONSerialization.jsonObject(with: data)
print("Response: \(json)")
} catch {
print("Request failed: \(error.localizedDescription)")
}
POST Request with JSON Body
var request = URLRequest(url: URL(string: "https://your-relay-server.com/users")!)
request.httpMethod = "POST"
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
request.addValue("Bearer abc123", forHTTPHeaderField: "Authorization")
let userData = ["name": "John Doe", "email": "john@example.com"]
request.httpBody = try? JSONSerialization.data(withJSONObject: userData)
do {
let (data, response) = try await relayInstance().dataTask(with: request)
if let httpResponse = response as? HTTPURLResponse {
print("Created user, status: \(httpResponse.statusCode)")
}
} catch {
print("Error: \(error.localizedDescription)")
}
dataTask parameters:
with(URLRequest) — the request you already build (URL, method, headers, body)unencryptedHeaders([String]?, defaults tonil) — header names to leave unencrypted on the hop to the relay, matched case-insensitively. Everything else is encrypted;nil/[]encrypts everything and is the right default.["*"]exposes every eligible header.Content-Type,Content-Length,Host,X-MTE-Relay-RouteandX-MTE-Relay-Clientare reserved and always encrypted — listing one throws. Exposing a header makes it readable by anything between your app and the relay, and the relay forwards it to your origin only if its operator allowed that namepathnamePrefix(String?, defaults tonil) — set this only if your relay is mounted under a path prefix rather than at the host root; otherwise omit it
Files & Server-Sent Events
relay.request(with:...)— the streaming form ofdataTask. YieldsRelayResponseEvent.response(HTTPURLResponse)once, then.chunk(Data)as decrypted bytes arrive, then finishes; errors throwRelayClientErrorfrom thefor try await, and leaving the loop early cancels the request. Use it when you want the status before the body is complete, or the body does not end. NotopenServerSentEventStream— that one is for subscribing to an SSE endpoint with a delegaterelay.uploadFileStream(request:...)/relay.downloadFile(with:...)— stream large files without buffering them in memoryRelayMultipartWriter— optional. If the endpoint you are uploading to expectsmultipart/form-data, this writes the framing into the streamuploadFileStreamhands your delegate, and gives you the matchingcontentTypeandcontentLength. The relay imposes no body format; use this only if your endpoint wants multipartrelay.openServerSentEventStream(with:...)/relay.cancelServerSentEventStream(...)— long-livedtext/event-streamresponses, delivered toRelayServerSentEventDelegate. Four things the delegate does not tell you: the data callback carries raw bytes chunked by the network, not one event per call (use the shippedRelaySseParser—append(_:) -> [Event]); an origin 404/500 opens successfully and carries its status on the result, so check it; an explicit cancel fires nodidComplete; and the delegate is held weakly, so an unretained one silently receives nothing. The protocol has no error parameter — if you need typed errors, userequest(with:)relay.rePairwithRelayServer(relayServerUrlString:)— force a fresh pairing handshake
See the GitHub README for full signatures and streaming examples.
Size limits
The relay advertises a maximum request body and applies it to streamed uploads as well as buffered ones. Earlier releases exempted streams; frame v2 has no such exemption.
do {
let (data, response) = try await relayInstance().uploadFileStream(request: request)
} catch RelayClientError.requestTooLarge(let size, let limit, _) {
// Nothing was sent. Both numbers come from the relay's own advertisement.
print("\(size) bytes exceeds this relay's \(limit) byte cap")
}
The client refuses before sending because the alternative is worse: past the cap the relay
stops reading while the upload keeps writing, so the request hangs until a timeout instead of
failing. It does not make large uploads work — that needs MAX_REQUEST_BODY_MIB raised on
the relay, which is a server-side decision.
Read a relay's own limit from maxRequestBodyBytes in its discovery document:
GET https://your-relay-server.com/api/mte-relay?frameVersions=2
frameVersions is mandatory; omitting it identifies you as a pre-version-2 SDK and the relay
answers 478.
Error Handling
Request methods throw RelayClientError (which conforms to LocalizedError, so error.localizedDescription is always meaningful). Match specific cases when you need to react:
do {
let (data, response) = try await relayInstance().dataTask(with: request)
// ...
} catch let error as RelayClientError {
switch error {
case .licenseCheckFailed:
print("MTE license invalid")
case .pairingFailed(let reason):
print("Pairing failed: \(reason)")
case .networkFailure:
print("Network failure — check connectivity / relay server")
case .requestTooLarge(let size, let limit, _):
print("\(size) bytes exceeds this relay's \(limit) byte cap; nothing was sent")
default:
print("Relay error: \(error.localizedDescription)")
}
} catch {
print("Unexpected error: \(error.localizedDescription)")
}
API Reference
See the complete documentation on GitHub for full details. Key types:
Relay: main interfaceinit(httpClient:) async throws— validate licence; pair lazily on first request.httpClientdefaults toURLSessionRelayHTTPClient(), soRelay()is a complete calldataTask(with:unencryptedHeaders:pathnamePrefix:) async throws -> (Data, URLResponse)— buffered. Everything exceptwithhas a default, sodataTask(with: request)is a complete callrequest(with:unencryptedHeaders:pathnamePrefix:) -> AsyncThrowingStream<RelayResponseEvent, Error>— streameduploadFileStream(request:...)·downloadFile(with:...)openServerSentEventStream(with:...)·cancelServerSentEventStream(...)rePairwithRelayServer(relayServerUrlString:)
RelayClientError: customer-facingLocalizedError(e.g.licenseCheckFailed,pairingFailed,networkFailure,requestTooLarge,incompatibleRelay,relayStatus,operationCancelled, …)
Support
Email: info@eclypses.com
Web: www.eclypses.com
Additional Resources
- GitHub Repository – Complete documentation with examples
- Release Notes – Latest updates and changes
All trademarks of Eclypses Inc. may not be used without Eclypses Inc.'s prior written consent.