Skip to main content
Version: 5.x

MTE Relay Client for iOS using Swift

Current SDK release: 5.3.0.

Upgrading from v4?

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 URLRequest you 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: YES and CODE_SIGN_IDENTITY: "-"

Installation​

  1. In Xcode, go to File > Add Package Dependencies...
  2. Enter the package URL: https://github.com/Eclypses/mte-relay-client-ios.git
  3. Choose the Up to Next Major Version rule starting from 5.3.0 and 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 like
path: ../mte-relay-client-ios
targets:
YourApp:
dependencies:
- package: Relay # the key above, not the package identity
product: Relay
// Package.swift
dependencies: [ .package(path: "../mte-relay-client-ios") ]

The Mte dependency (from mte-client-ios) is resolved transitively — no extra setup.

Setup​

  1. Import the module:
import Relay
  1. Create a Relay instance. init() is async 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 URLRequest at 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 to nil) — 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-Route and X-MTE-Relay-Client are 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 name
  • pathnamePrefix (String?, defaults to nil) — 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 of dataTask. Yields RelayResponseEvent.response(HTTPURLResponse) once, then .chunk(Data) as decrypted bytes arrive, then finishes; errors throw RelayClientError from the for 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. Not openServerSentEventStream — that one is for subscribing to an SSE endpoint with a delegate
  • relay.uploadFileStream(request:...) / relay.downloadFile(with:...) — stream large files without buffering them in memory
  • RelayMultipartWriter — optional. If the endpoint you are uploading to expects multipart/form-data, this writes the framing into the stream uploadFileStream hands your delegate, and gives you the matching contentType and contentLength. The relay imposes no body format; use this only if your endpoint wants multipart
  • relay.openServerSentEventStream(with:...) / relay.cancelServerSentEventStream(...) — long-lived text/event-stream responses, delivered to RelayServerSentEventDelegate. 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 shipped RelaySseParser — append(_:) -> [Event]); an origin 404/500 opens successfully and carries its status on the result, so check it; an explicit cancel fires no didComplete; and the delegate is held weakly, so an unretained one silently receives nothing. The protocol has no error parameter — if you need typed errors, use request(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 interface
    • init(httpClient:) async throws — validate licence; pair lazily on first request. httpClient defaults to URLSessionRelayHTTPClient(), so Relay() is a complete call
    • dataTask(with:unencryptedHeaders:pathnamePrefix:) async throws -> (Data, URLResponse) — buffered. Everything except with has a default, so dataTask(with: request) is a complete call
    • request(with:unencryptedHeaders:pathnamePrefix:) -> AsyncThrowingStream<RelayResponseEvent, Error> — streamed
    • uploadFileStream(request:...) · downloadFile(with:...)
    • openServerSentEventStream(with:...) · cancelServerSentEventStream(...)
    • rePairwithRelayServer(relayServerUrlString:)
  • RelayClientError: customer-facing LocalizedError (e.g. licenseCheckFailed, pairingFailed, networkFailure, requestTooLarge, incompatibleRelay, relayStatus, operationCancelled, …)

Support​

Email: info@eclypses.com
Web: www.eclypses.com

Additional Resources​


All trademarks of Eclypses Inc. may not be used without Eclypses Inc.'s prior written consent.