aboutsummaryrefslogtreecommitdiff
path: root/Hotline/Library/NetSocket/NetSocketNew.swift
diff options
context:
space:
mode:
authorDustin Mierau <dustin@mierau.me>2025-11-07 12:44:55 -0800
committerDustin Mierau <dustin@mierau.me>2025-11-07 12:44:55 -0800
commita55318fa8d643160900bec3e6b14e7404c63497a (patch)
treeab6a9eca9a368b35e1490becc70f94ebde6ac271 /Hotline/Library/NetSocket/NetSocketNew.swift
parent786a387d58fc66ae20716a9dee46b74dff8e6894 (diff)
Organize Library folder a bit. Update Tracker add/edit sheet design.
Diffstat (limited to 'Hotline/Library/NetSocket/NetSocketNew.swift')
-rw-r--r--Hotline/Library/NetSocket/NetSocketNew.swift1127
1 files changed, 0 insertions, 1127 deletions
diff --git a/Hotline/Library/NetSocket/NetSocketNew.swift b/Hotline/Library/NetSocket/NetSocketNew.swift
deleted file mode 100644
index 12f4271..0000000
--- a/Hotline/Library/NetSocket/NetSocketNew.swift
+++ /dev/null
@@ -1,1127 +0,0 @@
-// NetSocket.swift
-// Dustin Mierau • @mierau
-
-import Foundation
-import Network
-
-/// Byte order for multi-byte integer values in binary protocols
-public enum Endian {
- /// Big-endian (network byte order, most significant byte first)
- case big
- /// Little-endian (least significant byte first)
- case little
-}
-
-/// Delimiter patterns for text-based protocols
-public enum Delimiter {
- /// Custom single byte delimiter
- case byte(UInt8)
- /// Null terminator (0x00)
- case zeroByte
- /// Line feed (\n, 0x0A)
- case lineFeed
- /// Carriage return + line feed (\r\n, 0x0D 0x0A)
- case carriageReturnLineFeed
-
- /// Binary representation of this delimiter
- var data: Data {
- switch self {
- case .byte(let b): return Data([b])
- case .zeroByte: return Data([0x00])
- case .lineFeed: return Data([0x0A])
- case .carriageReturnLineFeed: return Data([0x0D, 0x0A])
- }
- }
-}
-
-/// TLS/SSL encryption policy for socket connections
-public struct TLSPolicy: Sendable {
- /// Create a TLS-enabled policy with optional custom configuration
- /// - Parameter configure: Optional closure to customize TLS options
- public static func enabled(_ configure: (@Sendable (NWProtocolTLS.Options) -> Void)? = nil) -> TLSPolicy {
- TLSPolicy(enabled: true, configure: configure)
- }
-
- /// Create a policy with TLS disabled (plaintext connection)
- public static var disabled: TLSPolicy { TLSPolicy(enabled: false, configure: nil) }
-
- /// Whether TLS is enabled
- public let enabled: Bool
- /// Optional TLS configuration closure
- public let configure: (@Sendable (NWProtocolTLS.Options) -> Void)?
-}
-
-// MARK: - Errors
-
-/// Errors that can occur during socket operations
-public enum NetSocketError: Error, CustomStringConvertible, Sendable {
- /// Socket is not yet in ready state
- case notReady
- /// Connection has been closed
- case closed
- /// Invalid port number provided
- case invalidPort
- /// Network operation failed with underlying error
- case failed(underlying: Error)
- /// Not enough data available to fulfill read request
- case insufficientData(expected: Int, got: Int)
- /// Frame size exceeds configured maximum
- case framingExceeded(max: Int)
- /// Failed to decode data
- case decodeFailed(Error)
- /// Failed to encode data
- case encodeFailed(Error)
-
- public var description: String {
- switch self {
- case .notReady: return "Connection not ready."
- case .closed: return "Connection closed."
- case .invalidPort: return "Invalid port number."
- case .failed(let e): return "Network failure: \(e.localizedDescription)"
- case .insufficientData(let exp, let got): return "Insufficient data: need \(exp), have \(got)."
- case .framingExceeded(let max): return "Frame length exceeded maximum \(max)."
- case .decodeFailed(let e): return "Decoding failed: \(e)"
- case .encodeFailed(let e): return "Encoding failed: \(e)"
- }
- }
-}
-
-/// An async/await TCP socket with automatic buffering and framing support
-///
-/// NetSocket provides:
-/// - Async connection management
-/// - Automatic receive buffering with memory compaction
-/// - Type-safe reading/writing of integers, strings, and custom types
-/// - File upload/download with progress tracking
-///
-/// Example usage:
-/// ```swift
-/// let socket = try await NetSocket.connect(host: "example.com", port: 80)
-/// try await socket.write("Hello\n".data(using: .utf8)!)
-/// let response = try await socket.readUntil(delimiter: .lineFeed)
-/// ```
-public actor NetSocket {
- /// Configuration options for the socket
- public struct Config: Sendable {
- /// Size of chunks to receive from network at once (default: 64 KB)
- public var receiveChunk: Int = 64 * 1024
- /// Maximum bytes to buffer before disconnecting (default: 8 MB)
- public var maxBufferBytes: Int = 8 * 1024 * 1024
- public init() {}
- }
-
- // Connection + state
- private let connection: NWConnection
- private let queue = DispatchQueue(label: "NetSocket.NWConnection")
- private var ready = false
- private var isClosed = false
- private let connectionID: String // For logging
-
- // Buffer with compaction
- private var buffer = Data()
- private var head = 0 // start of unread bytes
- private let config: Config
-
- // Waiters for data/ready
- private var dataWaiters: [CheckedContinuation<Void, Error>] = []
- private var readyWaiters: [CheckedContinuation<Void, Error>] = []
-
- // MARK: Init
-
- private init(connection: NWConnection, config: Config) {
- self.connection = connection
- self.config = config
- // Create a human-readable connection ID for logging
- if case .hostPort(host: let h, port: let p) = connection.endpoint {
- self.connectionID = "\(h):\(p)"
- } else {
- self.connectionID = "unknown"
- }
- }
-
- // MARK: Connect
-
- /// Connect to a remote host and return a ready socket
- ///
- /// This method establishes a TCP connection using Network framework types and waits until
- /// the connection is in `.ready` state.
- ///
- /// - Parameters:
- /// - host: Network framework host (e.g., `.name("example.com", nil)` or `.ipv4(...)`)
- /// - port: Network framework port
- /// - tls: TLS policy (default: enabled with default settings)
- /// - config: Socket configuration (default: standard settings)
- /// - Returns: A connected and ready `NetSocket`
- /// - Throws: Network errors or connection failures
- public static func connect(host: NWEndpoint.Host, port: NWEndpoint.Port, tls: TLSPolicy = .enabled(), config: Config = .init()) async throws -> NetSocket {
- let parameters = NWParameters.tcp
- if tls.enabled {
- let tlsOptions = NWProtocolTLS.Options()
- tls.configure?(tlsOptions)
- parameters.defaultProtocolStack.applicationProtocols.insert(tlsOptions, at: 0)
- }
-
- let conn = NWConnection(host: host, port: port, using: parameters)
- let socket = NetSocket(connection: conn, config: config)
- try await socket.start()
- return socket
- }
-
- /// Convenience wrapper to connect using string hostname and integer port
- public static func connect(host: String, port: UInt16, tls: TLSPolicy = .enabled(), config: Config = .init()) async throws -> NetSocket {
- guard let nwPort = NWEndpoint.Port(rawValue: port) else {
- throw NetSocketError.invalidPort
- }
- return try await self.connect(host: .name(host, nil), port: nwPort, tls: tls, config: config)
- }
-
- // MARK: Close
-
- /// Close the connection gracefully
- ///
- /// Performs a graceful shutdown of the underlying network connection (e.g., TCP FIN)
- /// and wakes all pending read/write operations with a `NetSocketError.closed` error.
- /// This method is idempotent - subsequent calls are ignored.
- ///
- /// Use `forceClose()` for immediate non-graceful termination (e.g., TCP RST).
- public func close() {
- guard !isClosed else { return }
- isClosed = true
- connection.cancel()
- resumeDataWaiters()
- resumeReadyWaiters(with: .failure(NetSocketError.closed))
- }
-
- /// Force close the connection immediately (non-graceful)
- ///
- /// Performs an immediate non-graceful shutdown of the underlying network connection
- /// (e.g., TCP RST). Use this when you need to terminate the connection immediately
- /// without waiting for graceful closure. For normal shutdown, use `close()` instead.
- ///
- /// This method is idempotent - subsequent calls are ignored.
- public func forceClose() {
- guard !isClosed else { return }
- isClosed = true
- connection.forceCancel()
- resumeDataWaiters()
- resumeReadyWaiters(with: .failure(NetSocketError.closed))
- }
-
- // MARK: Send Data
-
- /// Write raw data to the socket
- ///
- /// Sends data and waits for confirmation that it has been processed by the network stack.
- ///
- /// - Parameter data: Raw bytes to send
- /// - Throws: `NetSocketError` if connection is not ready or send fails
- @discardableResult
- public func write(_ data: Data) async throws -> Int {
- try await ensureReady()
- return try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Int, Error>) in
- connection.send(content: data, completion: .contentProcessed { error in
- if let error { cont.resume(throwing: NetSocketError.failed(underlying: error)) }
- else { cont.resume(returning: data.count) }
- })
- }
- }
-
- /// Write a fixed-width integer to the socket
- ///
- /// - Parameters:
- /// - value: The integer value to write
- /// - endian: Byte order (default: big-endian)
- /// - Throws: `NetSocketError` if write fails
- @discardableResult
- public func write<T: FixedWidthInteger>(_ value: T, endian: Endian = .big) async throws -> Int {
- var v = value
- switch endian {
- case .big: v = T(bigEndian: value)
- case .little: v = T(littleEndian: value)
- }
- var copy = v
- let size = MemoryLayout<T>.size
- let bytes = withUnsafePointer(to: &copy) {
- Data(bytes: $0, count: size)
- }
- try await write(bytes)
- return bytes.count
- }
-
- /// Write a boolean as a single byte (0 or 1)
- /// - Parameter value: Boolean value
- @discardableResult
- public func write(_ value: Bool) async throws -> Int {
- return try await write(UInt8(value ? 0x01 : 0x00))
- }
-
- /// Write a Float as its IEEE 754 bit pattern
- /// - Parameters:
- /// - value: Float value
- /// - endian: Byte order (default: big-endian)
- @discardableResult
- public func write(_ value: Float, endian: Endian = .big) async throws -> Int {
- return try await write(value.bitPattern, endian: endian)
- }
-
- /// Write a Double as its IEEE 754 bit pattern
- /// - Parameters:
- /// - value: Double value
- /// - endian: Byte order (default: big-endian)
- @discardableResult
- public func write(_ value: Double, endian: Endian = .big) async throws -> Int {
- return try await write(value.bitPattern, endian: endian)
- }
-
- /// Write a string to the socket, optionally length-prefixed
- ///
- /// - Parameters:
- /// - string: String to write
- /// - encoding: Text encoding (default: UTF-8)
- /// - allowLossyConversion: Allow lossy encoding if necessary (default: false)
- /// - Throws: `NetSocketError` if encoding fails or write fails
- @discardableResult
- public func write(_ string: String, encoding: String.Encoding = .utf8, allowLossyConversion: Bool = false) async throws -> Int {
- guard let data = string.data(using: encoding, allowLossyConversion: allowLossyConversion) else {
- throw NetSocketError.encodeFailed(NSError(domain: "StringEncoding", code: -1))
- }
- return try await write(data)
- }
-
- // MARK: Receive Data
-
- /// Read data until a delimiter is found
- ///
- /// Searches the buffer for the delimiter pattern and returns all data up to (and optionally including)
- /// the delimiter. The delimiter is always consumed from the stream.
- ///
- /// - Parameters:
- /// - delimiter: Binary delimiter pattern to search for
- /// - maxBytes: Maximum bytes to read before throwing (default: no limit)
- /// - includeDelimiter: Whether to include delimiter in result (default: false)
- /// - Returns: Data read from stream
- /// - Throws: `NetSocketError.framingExceeded` if max bytes exceeded, or connection errors
- public func read(past delimiter: Data, maxBytes: Int? = nil, includeDelimiter: Bool = false) async throws -> Data {
- while true {
- try Task.checkCancellation()
- if let r = search(delimiter: delimiter) {
- let consumeLen = r.upperBound - head
- let data = try await read(consumeLen)
- return includeDelimiter ? data : data.dropLast(delimiter.count)
- }
- if let maxBytes, availableBytes >= maxBytes {
- throw NetSocketError.framingExceeded(max: maxBytes)
- }
- try await waitForData()
- guard !isClosed || availableBytes > 0 else { throw NetSocketError.closed }
- }
- }
-
- /// Read exactly N bytes from the socket
- ///
- /// Waits for data to arrive if buffer doesn't contain enough bytes yet. The internal buffer
- /// is automatically compacted after reading to prevent unbounded memory growth.
- ///
- /// - Parameter count: Number of bytes to read
- /// - Returns: Exactly `count` bytes
- /// - Throws: `NetSocketError.insufficientData` if connection closes before enough data arrives
- public func read(_ count: Int) async throws -> Data {
- try await self.ensureReadable(count)
- let start = self.head
- let end = self.head + count
- let slice = self.buffer[start..<end]
- self.head = end
- self.compactIfNeeded()
- return Data(slice)
- }
-
- /// Read a fixed-width integer from the socket
- ///
- /// - Parameters:
- /// - type: Integer type to read
- /// - endian: Byte order (default: big-endian)
- /// - Returns: The integer value
- /// - Throws: `NetSocketError` if insufficient data or connection closed
- public func read<T: FixedWidthInteger>(_ type: T.Type = T.self, endian: Endian = .big) async throws -> T {
- let size = MemoryLayout<T>.size
- let data = try await self.read(size)
- let value: T = data.withUnsafeBytes { raw in
- raw.load(as: T.self)
- }
- switch endian {
- case .big: return T(bigEndian: value)
- case .little: return T(littleEndian: value)
- }
- }
-
- /// Read a fixed-length string
- ///
- /// - Parameters:
- /// - length: Number of bytes to read
- /// - encoding: Text encoding (default: UTF-8)
- /// - Returns: Decoded string
- /// - Throws: `NetSocketError` if decoding fails or insufficient data
- public func read(_ length: Int, encoding: String.Encoding = .utf8) async throws -> String {
- let data = try await self.read(length)
- guard let s = String(data: data, encoding: encoding) else {
- throw NetSocketError.decodeFailed(NSError())
- }
- return s
- }
-
- /// Read a string until a delimiter is found
- ///
- /// - Parameters:
- /// - delimiter: Delimiter pattern to search for
- /// - maxBytes: Maximum bytes to read before throwing (default: no limit)
- /// - includeDelimiter: Whether to include delimiter in result (default: false)
- /// - Returns: String read from stream (delimiter consumed but not included unless specified)
- /// - Throws: `NetSocketError` if decoding fails, max bytes exceeded, or connection closed
- public func read(until delimiter: Delimiter, maxBytes: Int? = nil, includeDelimiter: Bool = false) async throws -> String {
- let bytes = try await read(past: delimiter.data, maxBytes: maxBytes, includeDelimiter: includeDelimiter)
- guard let s = String(data: bytes, encoding: .utf8) else { throw NetSocketError.decodeFailed(NSError()) }
- return s
- }
-
- /// Read exactly N bytes with progress callbacks
- ///
- /// Like `read(_:)`, but reads in chunks and reports progress after each chunk.
- /// Useful for downloading large amounts of data where you want to update UI progress.
- ///
- /// Example:
- /// ```swift
- /// let data = try await socket.read(1_000_000) { current, total in
- /// print("Progress: \(current)/\(total)")
- /// }
- /// ```
- ///
- /// - Parameters:
- /// - count: Number of bytes to read
- /// - chunkSize: Size of chunks to read at a time (default: 8192)
- /// - progress: Optional callback with (bytesReceived, totalBytes)
- /// - Returns: Exactly `count` bytes
- /// - Throws: `NetSocketError` if connection closes before enough data arrives
- public func read(
- _ count: Int,
- chunkSize: Int = 8192,
- progress: (@Sendable (Int, Int) -> Void)? = nil
- ) async throws -> Data {
- var data = Data()
- data.reserveCapacity(count)
- var received = 0
-
- while received < count {
- try Task.checkCancellation()
- let toRead = min(chunkSize, count - received)
- let chunk = try await read(toRead)
- data.append(chunk)
- received += chunk.count
- progress?(received, count)
- }
-
- return data
- }
-
- // MARK: Peek Data
-
- public var availableBytes: Int { self.buffer.count - self.head }
-
- public func peek(_ count: Int) -> Data? {
- guard self.availableBytes >= count else {
- return nil
- }
-
- let slice = self.buffer[self.head..<(self.head + count)]
- return Data(slice) // Don't advance head
- }
-
- public func peek(upto count: Int) -> Data {
- let amount = min(self.availableBytes, count)
- guard amount > 0 else {
- return Data()
- }
-
- let slice = self.buffer[self.head..<(self.head + amount)]
- return Data(slice)
- }
-
- public func peek(awaiting count: Int) async throws -> Data {
- try await self.ensureReadable(count)
- let slice = self.buffer[self.head..<(self.head + count)]
- return Data(slice) // Don't advance head
- }
-
- // MARK: Skip Data
-
- /// Skip/discard exactly N bytes from the stream without allocating memory
- public func skip(_ count: Int) async throws {
- guard count > 0 else { return }
- try await self.ensureReadable(count)
- self.head += count
- self.compactIfNeeded()
- }
-
- /// Skip until delimiter is found (discards delimiter too)
- public func skip(past delimiter: Data) async throws {
- while true {
- try Task.checkCancellation()
- if let r = self.search(delimiter: delimiter) {
- self.head = r.upperBound // Skip to end of delimiter
- self.compactIfNeeded()
- return
- }
- try await self.waitForData()
- guard !self.isClosed else {
- throw NetSocketError.closed
- }
- }
- }
-
- // MARK: Files
-
- /// Upload a file from a URL, yielding progress as an AsyncSequence.
- ///
- /// Iterating this sequence drives the transfer. Each yielded value reports
- /// the total bytes sent so far and the known total. Cancel the consuming
- /// task to cancel the transfer.
- ///
- /// This method handles opening and closing the file handle automatically.
- ///
- /// - Parameters:
- /// - url: File URL to upload.
- /// - chunkSize: Size of each read chunk.
- /// - Returns: An `AsyncThrowingStream` of `FileProgress` updates.
- func writeFile(from url: URL, chunkSize: Int = 256 * 1024) -> AsyncThrowingStream<FileProgress, Error> {
- // This stream wrapper manages the FileHandle's lifetime.
- return AsyncThrowingStream(bufferingPolicy: .bufferingOldest(1)) { continuation in
- // Capture self (the actor) to use in detached task
- let actor = self
-
- // Open file on a background thread (file I/O is blocking)
- let task = Task.detached {
- let fh: FileHandle
- let total: Int
-
- // 1. Open file and get length (blocking I/O, done off-actor)
- do {
- total = Int(try NetSocket.fileLength(at: url))
- fh = try FileHandle(forReadingFrom: url)
- } catch {
- continuation.finish(throwing: NetSocketError.failed(underlying: error))
- return
- }
-
- // 2. Now switch to the actor context to call the actor-isolated method
- let stream = await actor.writeFile(
- from: fh,
- length: total,
- chunkSize: chunkSize
- )
-
- // 3. Forward all elements from the underlying stream to our stream
- do {
- for try await progress in stream {
- try Task.checkCancellation() // Exit early if cancelled
- continuation.yield(progress)
- }
- try? fh.close()
- continuation.finish()
- } catch is CancellationError {
- try? fh.close()
- continuation.finish()
- } catch {
- try? fh.close()
- continuation.finish(throwing: error)
- }
- }
-
- // If the *consumer* cancels the stream, we cancel our managing task.
- continuation.onTermination = { @Sendable _ in
- task.cancel()
- }
- }
- }
-
- /// Upload a file from an open FileHandle, yielding progress as an AsyncSequence.
- ///
- /// Iterating this sequence drives the transfer. Each yielded value reports
- /// the total bytes sent so far and the known total. Cancel the consuming
- /// task to cancel the transfer.
- ///
- /// **Note:** The caller is responsible for opening and closing the `fileHandle`.
- ///
- /// - Parameters:
- /// - fileHandle: Open `FileHandle` for reading.
- /// - length: Exact number of bytes to send (total file size).
- /// - chunkSize: Size of each read chunk.
- /// - Returns: An `AsyncThrowingStream` of `FileProgress` updates.
- func writeFile(from fileHandle: FileHandle, length: Int, chunkSize: Int = 256 * 1024) -> AsyncThrowingStream<FileProgress, Error> {
- precondition(length >= 0, "length must be >= 0")
-
- if length == 0 {
- return AsyncThrowingStream { continuation in
- continuation.yield(.init(sent: 0, total: 0, bytesPerSecond: 0, estimatedTimeRemaining: 0))
- continuation.finish()
- }
- }
-
- return AsyncThrowingStream(bufferingPolicy: .bufferingOldest(1)) { continuation in
- let task = Task { [weak self] in
- guard let self else {
- continuation.finish()
- return
- }
-
- var estimator = TransferRateEstimator(total: Int(length))
-
- do {
- try await self.ensureReady()
-
- while estimator.transferred < length {
- try Task.checkCancellation()
-
- let toRead = Int(min(chunkSize, length - estimator.transferred))
-
- // Read from disk
- guard let chunk = try fileHandle.read(upToCount: toRead), !chunk.isEmpty else {
- if estimator.transferred < length {
- throw NetSocketError.failed(underlying: NSError(
- domain: "NetSocket", code: 9001,
- userInfo: [NSLocalizedDescriptionKey: "File read ended prematurely. Expected \(length) bytes, got \(estimator.transferred)."]
- ))
- }
- break
- }
-
- // Write to network
- try await self.write(chunk)
-
- // Update estimator and yield progress
- let progress = estimator.update(bytes: chunk.count)
- continuation.yield(progress)
- }
-
- continuation.finish()
- } catch {
- continuation.finish(throwing: error)
- }
- }
-
- continuation.onTermination = { @Sendable _ in
- task.cancel()
- }
- }
- }
-
- /// Receive a file of known length and yield progress updates as an AsyncSequence.
- ///
- /// Iterating this sequence drives the transfer. Each yielded value reports
- /// the total bytes written so far and the known total. Cancel the consuming
- /// task to cancel the transfer.
- ///
- /// - Parameters:
- /// - fileHandle: Open `FileHandle` for writing (caller must close).
- /// - length: Exact number of bytes expected.
- /// - chunkSize: Size of each read chunk.
- /// - Returns: An `AsyncThrowingStream` of `FileProgress` updates.
- func receiveFile(to fileHandle: FileHandle, length: Int, chunkSize: Int = 256 * 1024) -> AsyncThrowingStream<FileProgress, Error> {
- precondition(length >= 0, "length must be >= 0")
-
- if length == 0 {
- return AsyncThrowingStream { continuation in
- continuation.yield(.init(sent: 0, total: 0, bytesPerSecond: 0, estimatedTimeRemaining: 0))
- continuation.finish()
- }
- }
-
- return AsyncThrowingStream(bufferingPolicy: .bufferingOldest(1)) { continuation in
- let task = Task { [weak self] in
- guard let self else {
- continuation.finish()
- return
- }
-
- var estimator = TransferRateEstimator(total: length)
-
- do {
- var remaining: Int = length
-
- while remaining > 0 {
- try Task.checkCancellation()
- let n = min(chunkSize, remaining)
-
- let chunk = try await self.read(n)
- try fileHandle.write(contentsOf: chunk)
-
- let chunkSize = Int(chunk.count)
- remaining -= chunkSize
- let progress = estimator.update(bytes: chunkSize)
- continuation.yield(progress)
- }
-
- continuation.finish()
- } catch {
- continuation.finish(throwing: error)
- }
- }
-
- continuation.onTermination = { @Sendable _ in
- task.cancel()
- }
- }
- }
-
- /// Download a file of known length and write it to disk in chunks
- ///
- /// This method does **not** read a length prefix. The caller must provide the expected
- /// file size (e.g., from protocol metadata). The file is streamed directly to disk to
- /// avoid loading it entirely into memory.
- ///
- /// Supports atomic writes: when enabled, data is written to a temporary `.part` file and
- /// renamed on success. If an error occurs, the temporary file is automatically cleaned up.
- ///
- /// - Parameters:
- /// - url: Destination file URL
- /// - length: Exact number of bytes to read (must match what's on the wire)
- /// - chunkSize: Chunk size for reading/writing (default: 256 KB)
- /// - overwrite: Whether to overwrite existing file (default: true)
- /// - atomic: Write to temporary file and rename on success (default: true)
- /// - progress: Optional progress callback
- /// - Returns: Total bytes written (equals `length` on success)
- /// - Throws: File I/O or network errors. On atomic writes, partial files are cleaned up.
- ///
- /// Example:
- /// ```swift
- /// // Hotline protocol: file size comes from transaction header
- /// let transaction = try await socket.receive(HotlineTransaction.self)
- /// try await socket.receiveFile(
- /// to: destinationURL,
- /// length: transaction.fileSize
- /// )
- /// ```
- @discardableResult
- func receiveFile(
- to url: URL,
- length: Int,
- chunkSize: Int = 256 * 1024,
- overwrite: Bool = true,
- atomic: Bool = true,
- progress: (@Sendable (FileProgress) -> Void)? = nil
- ) async throws -> Int {
- precondition(length >= 0, "length must be >= 0")
-
- // Fast path: nothing to do
- if length == 0 {
- if overwrite { try? FileManager.default.removeItem(at: url) }
- FileManager.default.createFile(atPath: url.path, contents: Data(), attributes: nil)
- return 0
- }
-
- // Prepare destination (optionally atomic)
- let fm = FileManager.default
- let dir = url.deletingLastPathComponent()
- let tmp = atomic ? dir.appendingPathComponent(".\(url.lastPathComponent).part-\(UUID().uuidString)") : url
-
- if overwrite { try? fm.removeItem(at: tmp) }
- if overwrite, !atomic { try? fm.removeItem(at: url) }
-
- // Create and open the file for writing
- fm.createFile(atPath: tmp.path, contents: nil, attributes: nil)
- let fh = try FileHandle(forWritingTo: tmp)
- defer { try? fh.close() }
-
- var remaining: Int = length
- var written: Int = 0
-
- do {
- while remaining > 0 {
- try Task.checkCancellation()
- let n = Int(min(chunkSize, remaining))
- let chunk = try await self.read(n)
- try fh.write(contentsOf: chunk)
- remaining -= n
- written += Int(n)
- progress?(.init(sent: written, total: length))
- }
- } catch {
- // Cleanup partial file on failure if we were writing atomically
- if atomic { try? fm.removeItem(at: tmp) }
- throw error
- }
-
- // Atomically move into place if requested
- if atomic {
- if overwrite { try? fm.removeItem(at: url) }
- try fm.moveItem(at: tmp, to: url)
- }
-
- return written
- }
-
- // MARK: Internals
-
- private func start() async throws {
- self.connection.stateUpdateHandler = { state in
- Task { [weak self] in
- guard let self else { return }
- switch state {
- case .ready:
- await self.setReady()
- await self.resumeReadyWaiters(with: .success(()))
- case .failed(let error):
- await self.failAllWaiters(NetSocketError.failed(underlying: error))
- await self.setClosed()
- case .waiting(let error):
- // bubble as transient failure for awaiters; reconnect logic could live here
- await self.resumeReadyWaiters(with: .failure(NetSocketError.failed(underlying: error)))
- case .cancelled:
- await self.failAllWaiters(NetSocketError.closed)
- await self.setClosed()
- default:
- break
- }
- }
- }
-
- // Kick off receive loop after .start
- self.connection.start(queue: queue)
- try await self.waitUntilReady()
- self.startReceiveLoop()
- }
-
- private func startReceiveLoop() {
- @Sendable func loop(_ connection: NWConnection, chunk: Int, owner: NetSocket, connID: String) {
- print("NetSocket[\(connID)]: Calling connection.receive(\(chunk)) to request more data...")
-
- connection.receive(minimumIncompleteLength: 1, maximumLength: chunk) { [weak owner] data, _, isComplete, error in
- print("NetSocket[\(connID)]: Receive callback - data: \(data?.count ?? 0) bytes, isComplete: \(isComplete), error: \(String(describing: error))")
- Task {
- guard let o = owner else {
- return
- }
-
- if let error {
- await o.handleReceiveError(error)
- return
- }
- if let data, !data.isEmpty {
- await o.append(data, connID: connID)
- }
- if isComplete {
- print("NetSocket[\(connID)]: EOF from peer.")
- await o.handleEOF()
- return
- }
- loop(connection, chunk: chunk, owner: o, connID: connID)
- }
- }
- }
- loop(connection, chunk: self.config.receiveChunk, owner: self, connID: connectionID)
- }
-
- private func handleReceiveError(_ error: Error) {
- self.isClosed = true
- self.failAllWaiters(NetSocketError.failed(underlying: error))
- }
-
- private func handleEOF() {
- self.isClosed = true
- let waiters = self.dataWaiters
- self.dataWaiters.removeAll()
- for w in waiters {
- w.resume()
- } // wake so readers can observe closure
- }
-
- private func setReady() {
- self.ready = true
- }
-
- private func setClosed() {
- self.isClosed = true
- }
-
- private func ensureReady() async throws {
- if self.isClosed {
- throw NetSocketError.closed
- }
- if !self.ready {
- try await self.waitUntilReady()
- }
- }
-
- private func ensureReadable(_ count: Int) async throws {
- try await self.ensureReady()
- while self.availableBytes < count {
- try Task.checkCancellation()
- if self.isClosed {
- throw NetSocketError.insufficientData(expected: count, got: self.availableBytes)
- }
- try await self.waitForData()
- }
- }
-
- private func waitForData() async throws {
- try Task.checkCancellation()
- try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
- if self.isClosed {
- cont.resume()
- return
- }
- self.dataWaiters.append(cont)
- }
- }
-
- private func compactIfNeeded() {
- // Avoid unbounded memory as head advances
- if self.head > 64 * 1024 && self.head > self.buffer.count / 2 {
- self.buffer.removeSubrange(0..<self.head)
- self.head = 0
- }
- }
-
- private func search(delimiter: Data) -> Range<Int>? {
- guard !delimiter.isEmpty, availableBytes >= delimiter.count else { return nil }
- let hay = buffer[head..<buffer.count]
-
- // Fast path for single-byte delimiters
- if delimiter.count == 1, let byte = delimiter.first {
- if let idx = hay.firstIndex(of: byte) {
- let pos = head + hay.distance(from: hay.startIndex, to: idx)
- return pos..<(pos + 1)
- }
- return nil
- }
-
- // General case
- if let r = hay.firstRange(of: delimiter) {
- let lower = head + hay.distance(from: hay.startIndex, to: r.lowerBound)
- let upper = head + hay.distance(from: hay.startIndex, to: r.upperBound)
- return lower..<upper
- }
-
- return nil
- }
-
- private static func fileLength(at url: URL) throws -> Int64 {
- let values = try url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey])
- guard values.isRegularFile == true else {
- throw NetSocketError.failed(underlying: NSError(
- domain: "NetSocket", code: 1001,
- userInfo: [NSLocalizedDescriptionKey: "Not a regular file: \(url.path)"]
- ))
- }
- if let s = values.fileSize { return Int64(s) }
- let attrs = try FileManager.default.attributesOfItem(atPath: url.path)
- if let n = attrs[.size] as? NSNumber {
- return n.int64Value
- }
- throw NetSocketError.failed(underlying: NSError(
- domain: "NetSocket", code: 1002,
- userInfo: [NSLocalizedDescriptionKey: "Unable to determine file size for \(url.lastPathComponent)"]
- ))
- }
-
- private func waitUntilReady() async throws {
- guard !self.ready else { return }
- try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
- self.readyWaiters.append(cont)
- }
- }
-
- private func resumeReadyWaiters(with result: Result<Void, Error>) {
- let waiters = self.readyWaiters
- self.readyWaiters.removeAll()
- for w in waiters {
- switch result {
- case .success: w.resume()
- case .failure(let e): w.resume(throwing: e)
- }
- }
- }
-
- private func failAllWaiters(_ error: Error) {
- self.resumeReadyWaiters(with: .failure(error))
- let waiters = self.dataWaiters
- self.dataWaiters.removeAll()
- for w in waiters {
- w.resume(throwing: error)
- }
- }
-
- private func append(_ data: Data, connID: String) {
- print("NetSocket[\(connID)]: Received \(data.count) bytes from network, buffer now has \(buffer.count - head + data.count) available")
- buffer.append(data)
- if buffer.count - head > config.maxBufferBytes {
- // Hard stop: drop connection rather than OOM'ing.
- isClosed = true
- connection.cancel()
- failAllWaiters(NetSocketError.framingExceeded(max: config.maxBufferBytes))
- return
- }
- resumeDataWaiters()
- }
-
- private func resumeDataWaiters() {
- let waiters = dataWaiters
- dataWaiters.removeAll()
- for w in waiters { w.resume() }
- }
-}
-
-// MARK: - Utilities
-
-private extension Data {
- mutating func appendInteger<T: FixedWidthInteger>(_ value: T, endian: Endian) throws {
- var v = value
- switch endian {
- case .big: v = T(bigEndian: value)
- case .little: v = T(littleEndian: value)
- }
- var copy = v
- withUnsafePointer(to: &copy) { ptr in
- self.append(contentsOf: UnsafeRawBufferPointer(start: ptr, count: MemoryLayout<T>.size))
- }
- }
-}
-
-// MARK: - NetSocketEncodable
-
-/// Protocol for types that can encode themselves to binary data
-///
-/// Types conforming to `NetSocketEncodable` produce binary data that can be sent over
-/// a socket. Unlike writing field-by-field to the socket, encodable types build complete
-/// binary messages that are sent in a single write operation for efficiency.
-///
-/// Example:
-/// ```swift
-/// struct MyMessage: NetSocketEncodable {
-/// let id: UInt32
-/// let name: String
-///
-/// func encode(endian: Endian) throws -> Data {
-/// var data = Data()
-/// // Encode fields to data...
-/// return data
-/// }
-/// }
-///
-/// try await socket.send(message)
-/// ```
-public protocol NetSocketEncodable: Sendable {
- /// Encode this value to binary data
- ///
- /// Implementations should build a complete binary message and return it as Data.
- /// The data will be sent to the socket in a single write operation.
- ///
- /// - Parameter endian: Byte order for multi-byte values
- /// - Returns: Encoded binary data ready to send
- /// - Throws: Encoding errors
- func encode(endian: Endian) throws -> Data
-}
-
-/// Protocol for types that can decode themselves directly from a socket stream
-///
-/// Types conforming to `NetSocketDecodable` read field-by-field directly from the socket
-/// using async reads. This enables true streaming without buffering entire messages.
-///
-/// **Important**: If decoding throws after consuming some bytes (e.g., validation fails),
-/// the socket will be left with those bytes consumed. In practice, this usually means the
-/// connection should be closed. For most protocols this is acceptable since decode errors
-/// indicate corrupt data or protocol violations.
-///
-/// Example:
-/// ```swift
-/// struct MyMessage: NetSocketDecodable {
-/// let id: UInt32
-/// let name: String
-///
-/// init(from socket: NetSocket, endian: Endian) async throws {
-/// self.id = try await socket.read(UInt32.self, endian: endian)
-/// let nameLen = try await socket.read(UInt16.self, endian: endian)
-/// let nameData = try await socket.readExactly(Int(nameLen))
-/// guard let name = String(data: nameData, encoding: .utf8) else {
-/// throw NetSocketError.decodeFailed(NSError())
-/// }
-/// self.name = name
-/// }
-/// }
-///
-/// let message = try await socket.receive(MyMessage.self)
-/// ```
-public protocol NetSocketDecodable: Sendable {
- /// Decode a value by reading directly from the socket stream
- ///
- /// This initializer should read all necessary fields from the socket using
- /// methods like `read(_:endian:)`, `readExactly(_:)`, `readString(length:)`, etc.
- ///
- /// The socket handles waiting for data to arrive, so you can read field by field
- /// without worrying about buffering.
- ///
- /// - Parameters:
- /// - socket: Socket to read from
- /// - endian: Byte order for multi-byte values
- /// - Throws: Network errors, insufficient data, or custom decoding errors
- init(from socket: NetSocket, endian: Endian) async throws
-}
-
-public extension NetSocket {
- /// Send an encodable value to the socket
- ///
- /// The type encodes itself to binary data, which is then sent in a single write operation.
- ///
- /// Example:
- /// ```swift
- /// struct MyMessage: NetSocketEncodable {
- /// let id: UInt32
- /// let name: String
- ///
- /// func encode(endian: Endian) throws -> Data {
- /// var data = Data()
- /// // Build binary message...
- /// return data
- /// }
- /// }
- ///
- /// try await socket.send(message)
- /// ```
- ///
- /// - Parameters:
- /// - value: Value conforming to NetSocketEncodable
- /// - endian: Byte order (default: big-endian)
- /// - Throws: Encoding or network errors
- func send<T: NetSocketEncodable>(_ value: T, endian: Endian = .big) async throws {
- let data = try value.encode(endian: endian)
- try await self.write(data)
- }
-
- /// Receive and decode a value directly from the socket stream (no length prefix)
- ///
- /// The type reads field-by-field from the socket as needed, enabling true streaming
- /// without buffering entire messages. Useful for protocols where message size isn't
- /// known upfront or for progressive decoding.
- ///
- /// Example:
- /// ```swift
- /// struct ServerEntry: NetSocketDecodable {
- /// let id: UInt32
- /// let name: String
- ///
- /// init(from socket: NetSocket, endian: Endian) async throws {
- /// self.id = try await socket.read(UInt32.self, endian: endian)
- /// // Read variable-length string...
- /// }
- /// }
- ///
- /// let entry = try await socket.receive(ServerEntry.self)
- /// ```
- ///
- /// - Parameters:
- /// - type: Type conforming to NetSocketDecodable
- /// - endian: Byte order (default: big-endian)
- /// - Returns: Decoded value
- /// - Throws: Decoding or network errors
- func receive<T: NetSocketDecodable>(_ type: T.Type, endian: Endian = .big) async throws -> T {
- return try await T(from: self, endian: endian)
- }
-}