a789495a98
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
99 lines
3.5 KiB
Swift
99 lines
3.5 KiB
Swift
import Foundation
|
|
|
|
/// Abstraction over a WebSocket so `Connection` is testable with a fake.
|
|
public protocol WebSocketTransport: Sendable {
|
|
/// Opens the socket. Throws if the connection or auth fails.
|
|
func connect(url: URL, authToken: String) async throws
|
|
/// Sends one text frame.
|
|
func send(text: String) async throws
|
|
/// Receives the next text frame. Returns nil when the socket closes.
|
|
func receiveText() async throws -> String?
|
|
/// Closes the socket.
|
|
func close() async
|
|
}
|
|
|
|
/// URLSession-backed transport used in production.
|
|
///
|
|
/// Auth is sent via `Authorization: Bearer <token>` on the upgrade request,
|
|
/// matching the gateway's preferred auth source.
|
|
public actor URLSessionWebSocketTransport: WebSocketTransport {
|
|
private var task: URLSessionWebSocketTask?
|
|
|
|
public init() {}
|
|
|
|
public func connect(url: URL, authToken: String) async throws {
|
|
var request = URLRequest(url: url)
|
|
request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization")
|
|
request.timeoutInterval = 10
|
|
let task = URLSession.shared.webSocketTask(with: request)
|
|
task.resume()
|
|
self.task = task
|
|
// Force the handshake to complete (and surface auth failures) by
|
|
// sending a WebSocket-level ping before declaring success.
|
|
do {
|
|
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
|
|
task.sendPing { error in
|
|
if let error {
|
|
cont.resume(throwing: error)
|
|
} else {
|
|
cont.resume()
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// The gateway rejects unknown/revoked tokens at the upgrade with
|
|
// 401. Surface that distinctly so the connection loop can stop
|
|
// retrying and prompt a re-pair instead of backing off forever.
|
|
if let http = task.response as? HTTPURLResponse, http.statusCode == 401 {
|
|
throw TransportError.unauthorized
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
public func send(text: String) async throws {
|
|
guard let task else { throw TransportError.notConnected }
|
|
try await task.send(.string(text))
|
|
}
|
|
|
|
public func receiveText() async throws -> String? {
|
|
guard let task else { throw TransportError.notConnected }
|
|
while true {
|
|
let message: URLSessionWebSocketTask.Message
|
|
do {
|
|
message = try await task.receive()
|
|
} catch {
|
|
// Treat close as end-of-stream rather than error when the
|
|
// task reports a normal closure.
|
|
if task.closeCode != .invalid {
|
|
return nil
|
|
}
|
|
throw error
|
|
}
|
|
switch message {
|
|
case .string(let text):
|
|
return text
|
|
case .data(let data):
|
|
if let text = String(data: data, encoding: .utf8) {
|
|
return text
|
|
}
|
|
@unknown default:
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
|
|
public func close() async {
|
|
task?.cancel(with: .normalClosure, reason: nil)
|
|
task = nil
|
|
}
|
|
}
|
|
|
|
public enum TransportError: Error, Equatable {
|
|
case notConnected
|
|
/// The server rejected the WebSocket upgrade as unauthorized (401):
|
|
/// the pairing token is unknown or was revoked. Reconnecting with the
|
|
/// same token cannot succeed; the device must re-pair.
|
|
case unauthorized
|
|
}
|