// // KCPClient.swift // AT-IRC // // Thin async client over the embedded kcp apiserver. AtircgoKCPRequest is // synchronous and runs in-process over a net.Pipe; requests are moved off the // main actor. kcp answers with a JSON envelope // {status, headers, bodyJSON|bodyBase64}. // import Foundation import AtircGo struct KCPResponse { let status: Int /// Decoded body, when it is JSON. let json: Any? let data: Data } enum KCPError: LocalizedError { case notReady case transport(String) case malformedResponse case http(status: Int, message: String) var errorDescription: String? { switch self { case .notReady: return "kcp is not running" case .transport(let message): return "kcp request failed: \(message)" case .malformedResponse: return "kcp returned an unexpected response" case .http(let status, let message): return "kcp HTTP \(status): \(message)" } } var isNotFound: Bool { if case .http(404, _) = self { return true } return false } } struct KCPClient: Sendable { func get(_ path: String) async throws -> KCPResponse { try await request("GET", path) } /// GET with a bounded retry on 404, for resources that appear a moment /// after the embedded requester starts (APIBinding, CRDs). func getRetryingNotFound( _ path: String, attempts: Int, delay: Duration ) async throws -> KCPResponse { var lastError: Error = KCPError.malformedResponse for _ in 0.. KCPResponse { guard AtircgoKCPReady() else { throw KCPError.notReady } // The app talks to the apiserver, not through the requester's // in-process exposure (X-Atirc-Direct); once the requester runs its // handlers otherwise answer /apis/socialweb... from their own view. var headerFields: [String: String] = [:] if let body { headerFields["Content-Type"] = contentType } if direct { headerFields["X-Atirc-Direct"] = "1" } let headersData = try JSONSerialization.data(withJSONObject: headerFields) let headers = String(decoding: headersData, as: UTF8.self) let bodyText = body.map { String(decoding: $0, as: UTF8.self) } ?? "" let raw: (response: String, error: String?) = try await Task.detached(priority: .userInitiated) { var requestError: NSError? let response = AtircgoKCPRequest(method, path, headers, bodyText, &requestError) return (response, requestError?.localizedDescription) }.value if let error = raw.error { throw KCPError.transport(error) } guard let rawData = raw.response.data(using: .utf8), let envelope = try? JSONSerialization.jsonObject(with: rawData) as? [String: Any], let status = envelope["status"] as? Int else { throw KCPError.malformedResponse } let data: Data if let base64 = envelope["bodyBase64"] as? String { data = Data(base64Encoded: base64) ?? Data() } else if let bodyJSON = envelope["bodyJSON"] { data = (try? JSONSerialization.data(withJSONObject: bodyJSON)) ?? Data() } else { data = Data() } var json: Any? if !data.isEmpty { json = try? JSONSerialization.jsonObject(with: data) } if !(200..<300).contains(status) { let object = json as? [String: Any] let message = object?["message"] as? String ?? object?["reason"] as? String ?? "HTTP \(status)" throw KCPError.http(status: status, message: message) } return KCPResponse(status: status, json: json, data: data) } }