rastrillo / native Public

Clone
git clone https://amadan.net/rastrillo/native

Plain git — no account needed to clone.

Download

Download this file

1import Foundation
2
3public struct NativeSession: Codable, Sendable {
4 public let name: String
5 public let value: String
6 public init(name: String, value: String) { self.name = name; self.value = value }
7}
8
9private final class NoRedirects: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
10 func urlSession(_ session: URLSession, task: URLSessionTask,
11 willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest,
12 completionHandler: @escaping (URLRequest?) -> Void) { completionHandler(nil) }
13}
14
15/// Ephemeral transport: no HTTP cache, shared cookie jar, or off-origin redirects.
16public final class NativeHTTP: NativeTransport, @unchecked Sendable {
17 public let origin: URL
18 public let credential: NativeSession?
19 private let session: URLSession
20 private let observer: (@Sendable (NativeTransportOutcome) -> Void)?
21
22 /// `observer` hears every response that arrives and every offline-class
23 /// failure, so connectivity can be learned from real traffic instead of a probe.
24 public init(origin: URL, credential: NativeSession? = nil,
25 observer: (@Sendable (NativeTransportOutcome) -> Void)? = nil) {
26 self.origin = origin
27 self.credential = credential
28 self.observer = observer
29 let config = URLSessionConfiguration.ephemeral
30 config.httpShouldSetCookies = false
31 config.urlCache = nil
32 config.timeoutIntervalForRequest = 30
33 session = URLSession(configuration: config, delegate: NoRedirects(), delegateQueue: nil)
34 }
35
36 public static func origin(_ text: String) throws -> URL {
37 guard let c = URLComponents(string: text.trimmingCharacters(in: .whitespacesAndNewlines)),
38 let host = c.host, !host.isEmpty, c.user == nil, c.password == nil,
39 c.query == nil, c.fragment == nil, c.path.isEmpty || c.path == "/",
40 c.scheme == "https" || (c.scheme == "http" && ["localhost", "127.0.0.1", "[::1]"].contains(host)),
41 let url = c.url else {
42 throw NativeClientError.message("Enter your server’s HTTPS address, without a page path.")
43 }
44 var clean = c
45 clean.path = ""
46 return clean.url ?? url
47 }
48
49 private func makeRequest(_ path: String, method: String, body: Data?, contentType: String) throws -> URLRequest {
50 guard path.hasPrefix("/"), !path.hasPrefix("//"),
51 let url = URL(string: path, relativeTo: origin)?.absoluteURL,
52 url.scheme == origin.scheme, url.host == origin.host, url.port == origin.port else {
53 throw NativeClientError.message("Invalid server path.")
54 }
55 var request = URLRequest(url: url)
56 request.httpMethod = method
57 request.httpBody = body
58 request.setValue(origin.absoluteString, forHTTPHeaderField: "Origin")
59 request.setValue("application/json", forHTTPHeaderField: "Accept")
60 if body != nil { request.setValue(contentType, forHTTPHeaderField: "Content-Type") }
61 if let credential { request.setValue("\(credential.name)=\(credential.value)", forHTTPHeaderField: "Cookie") }
62 return request
63 }
64
65 /// A raw URLError used to escape from here, so "offline" could not be told
66 /// apart from any other failure. Every transport failure is typed on its way out.
67 private func typed(_ error: Error) -> Error {
68 let mapped = NativeClientError.classify(error)
69 if case NativeClientError.offline(let code) = mapped { observer?(.offline(code)) }
70 return mapped
71 }
72
73 private func arrived(_ response: URLResponse) throws -> HTTPURLResponse {
74 guard let response = response as? HTTPURLResponse else { throw NativeClientError.invalidResponse }
75 observer?(.responded(status: response.statusCode))
76 return response
77 }
78
79 private static func headers(_ response: HTTPURLResponse) -> [String: String] {
80 var headers: [String: String] = [:]
81 for (key, value) in response.allHeaderFields {
82 if let key = key as? String, let value = value as? String { headers[key.lowercased()] = value }
83 }
84 return headers
85 }
86
87 /// Reads the next byte, typing a connection lost mid-body as offline. The
88 /// consumer's own callbacks run outside this, so their errors pass untouched.
89 private func next(_ iterator: inout URLSession.AsyncBytes.AsyncIterator) async throws -> UInt8? {
90 do { return try await iterator.next() } catch { throw typed(error) }
91 }
92
93 /// An explicit export to a new file, streamed by URLSession without retaining
94 /// the workbook/document in memory. The caller owns deletion after export or
95 /// cancellation of its save panel. Existing destinations are never replaced.
96 public func download(_ path: String, to destination: URL) async throws {
97 guard destination.isFileURL else { throw NativeClientError.message("Invalid export destination.") }
98 let request = try makeRequest(path, method: "GET", body: nil, contentType: "application/json")
99 let temporary: URL, received: URLResponse
100 do { (temporary, received) = try await session.download(for: request) } catch { throw typed(error) }
101 defer { try? FileManager.default.removeItem(at: temporary) }
102 try Task.checkCancellation()
103 let response = try arrived(received)
104 if let failure = NativeClientError.forStatus(response.statusCode, body: Data(), headers: Self.headers(response)) { throw failure }
105 guard response.statusCode == 200 else {
106 throw NativeClientError.message("The export could not be completed. Refresh your session and try again.")
107 }
108 try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: temporary.path)
109 try FileManager.default.moveItem(at: temporary, to: destination)
110 }
111
112 /// Exposes protocol cursors (for example Docs-Seq) without sharing a cookie jar.
113 /// Returns any status; see `NativeResponse.requireSuccess()`.
114 public func responseDetails(_ path: String, method: String, body: Data?,
115 contentType: String, maxBytes: Int) async throws -> NativeResponse {
116 guard maxBytes > 0, maxBytes <= 256 * 1024 * 1024 else {
117 throw NativeClientError.message("Invalid response size limit.")
118 }
119 let request = try makeRequest(path, method: method, body: body, contentType: contentType)
120 let bytes: URLSession.AsyncBytes, received: URLResponse
121 do { (bytes, received) = try await session.bytes(for: request) } catch { throw typed(error) }
122 defer { bytes.task.cancel() }
123 let response = try arrived(received)
124 var data = Data()
125 var iterator = bytes.makeAsyncIterator()
126 while let byte = try await next(&iterator) {
127 try Task.checkCancellation()
128 guard data.count < maxBytes else { throw NativeClientError.message("The server response is too large.") }
129 data.append(byte)
130 }
131 return NativeResponse(data: data, status: response.statusCode, headers: Self.headers(response))
132 }
133
134 /// Delivers bounded UTF-8 SSE lines, including blank event delimiters. Callers
135 /// own event parsing and reconnect cursors; a clean EOF is never a saved edit.
136 public func stream(_ path: String, onOpen: @Sendable () async throws -> Void,
137 onLine: @Sendable (String) async throws -> Void) async throws {
138 var request = try makeRequest(path, method: "GET", body: nil, contentType: "application/json")
139 request.setValue("text/event-stream", forHTTPHeaderField: "Accept")
140 let bytes: URLSession.AsyncBytes, received: URLResponse
141 do { (bytes, received) = try await session.bytes(for: request) } catch { throw typed(error) }
142 defer { bytes.task.cancel() }
143 let response = try arrived(received)
144 // A 401 on the stream is an ended session, not a generic refusal: the
145 // app must not advise signing out while it holds unsent edits.
146 if let failure = NativeClientError.forStatus(response.statusCode, body: Data(), headers: Self.headers(response)) { throw failure }
147 guard response.statusCode == 200,
148 response.value(forHTTPHeaderField: "Content-Type")?.split(separator: ";").first?.trimmingCharacters(in: .whitespaces).lowercased() == "text/event-stream" else {
149 throw NativeClientError.message("The live connection was refused. Reconnect to refresh your session.")
150 }
151 // A server that subscribes before flushing headers lets this callback fetch
152 // a snapshot without a subscribe-after-snapshot gap. Lines remain buffered
153 // until the callback completes; consumers reconcile their version cursors.
154 try Task.checkCancellation()
155 try await onOpen()
156 var parser = NativeStreamLines()
157 var iterator = bytes.makeAsyncIterator()
158 while let byte = try await next(&iterator) {
159 try Task.checkCancellation()
160 if let line = try parser.append(byte) { try await onLine(line) }
161 }
162 // An unterminated event is not delivered at EOF; the next snapshot is the
163 // authority after reconnection, rather than an incomplete network frame.
164 }
165}
166