aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDustin Mierau <dustin@mierau.me>2025-10-27 10:11:57 -0700
committerDustin Mierau <dustin@mierau.me>2025-10-27 10:11:57 -0700
commit26c6e0ade508f319ed995a1e6c5a53bf7c610c34 (patch)
tree5f48b236adca496b0022db61661546f4a4d9e216
parentb7e909cccfaa8611522f488134f9f28f643b83eb (diff)
Further work on NetSocketNew. Added NetSocketDecodable/NetSocketEncodable so types can decode/encode themselves on/off the wire.
-rw-r--r--Hotline/Hotline/HotlineProtocol.swift62
-rw-r--r--Hotline/Hotline/HotlineTrackerClient.swift118
-rw-r--r--Hotline/Library/NetSocketNew.swift547
3 files changed, 267 insertions, 460 deletions
diff --git a/Hotline/Hotline/HotlineProtocol.swift b/Hotline/Hotline/HotlineProtocol.swift
index 9b3a812..5f859fb 100644
--- a/Hotline/Hotline/HotlineProtocol.swift
+++ b/Hotline/Hotline/HotlineProtocol.swift
@@ -164,21 +164,41 @@ struct HotlineUserAccessOptions: OptionSet {
static let canSendMessages = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(40))
}
-struct HotlineServer: Identifiable, Hashable {
+struct HotlineServer: Identifiable, Hashable, NetSocketDecodable {
let id = UUID()
let address: String
let port: UInt16
let users: UInt16
let name: String?
let description: String?
-
+
static func == (lhs: HotlineServer, rhs: HotlineServer) -> Bool {
return lhs.id == rhs.id
}
-
+
func hash(into hasher: inout Hasher) {
hasher.combine(self.id)
}
+
+ init(from socket: NetSocketNew, endian: Endian) async throws {
+ // Read IP address (4 individual bytes)
+ let ip1 = try await socket.read(UInt8.self)
+ let ip2 = try await socket.read(UInt8.self)
+ let ip3 = try await socket.read(UInt8.self)
+ let ip4 = try await socket.read(UInt8.self)
+ self.address = "\(ip1).\(ip2).\(ip3).\(ip4)"
+
+ // Read port and user count (big-endian)
+ self.port = try await socket.read(UInt16.self, endian: endian)
+ self.users = try await socket.read(UInt16.self, endian: endian)
+
+ // Skip 2 unused bytes
+ _ = try await socket.read(2)
+
+ // Read pascal strings (auto-detects encoding)
+ self.name = try await socket.readPascalString()
+ self.description = try await socket.readPascalString()
+ }
}
struct HotlineNewsArticle: Identifiable {
@@ -216,7 +236,6 @@ struct HotlineAccount: Identifiable {
self.persisted = false
}
-
mutating func decodeFields(from data: [UInt8]) {
var fieldData = data
@@ -967,6 +986,39 @@ enum HotlineTransactionType: UInt16 {
case postNewsArticle = 410
case deleteNewsArticle = 411
case connectionKeepAlive = 500
-
+
case unknown = 15000
}
+
+// MARK: - NetSocketDecodable Conformance
+//
+//extension HotlineTrackerServer: NetSocketDecodable {
+// /// Decode a tracker server entry directly from the socket stream
+// ///
+// /// Wire format:
+// /// - IP address: 4 bytes (individual octets)
+// /// - Port: UInt16 (big-endian)
+// /// - User count: UInt16 (big-endian)
+// /// - Unused: 2 bytes
+// /// - Server name: Pascal string (1-byte length + data)
+// /// - Server description: Pascal string (1-byte length + data)
+// init(from socket: NetSocketNew, endian: Endian) async throws {
+// // Read IP address (4 individual bytes)
+// let ip1 = try await socket.read(UInt8.self)
+// let ip2 = try await socket.read(UInt8.self)
+// let ip3 = try await socket.read(UInt8.self)
+// let ip4 = try await socket.read(UInt8.self)
+// self.address = "\(ip1).\(ip2).\(ip3).\(ip4)"
+//
+// // Read port and user count (big-endian)
+// self.port = try await socket.read(UInt16.self, endian: endian)
+// self.users = try await socket.read(UInt16.self, endian: endian)
+//
+// // Skip 2 unused bytes
+// _ = try await socket.read(2)
+//
+// // Read pascal strings (auto-detects encoding)
+// self.name = try await socket.readPascalString()
+// self.description = try await socket.readPascalString()
+// }
+//}
diff --git a/Hotline/Hotline/HotlineTrackerClient.swift b/Hotline/Hotline/HotlineTrackerClient.swift
index 110ca21..52d34c1 100644
--- a/Hotline/Hotline/HotlineTrackerClient.swift
+++ b/Hotline/Hotline/HotlineTrackerClient.swift
@@ -83,7 +83,7 @@ class HotlineTrackerClient {
try await socket.write(Data(HotlineTrackerClient.MagicPacket))
// Receive magic response (6 bytes: 'HTRK' + version)
- let magicResponse = try await socket.readExactly(6)
+ let magicResponse = try await socket.read(6)
let magic = magicResponse[0..<4]
let version = UInt16(magicResponse[4]) << 8 | UInt16(magicResponse[5])
@@ -121,52 +121,33 @@ class HotlineTrackerClient {
print("HotlineTrackerClient: Batch #\(batchCount) - type: \(messageType), dataLen: \(dataLength), count1: \(serverCount), count2: \(serverCount2)")
- // Calculate actual listing data length (excludes the two server count fields)
- let listingLength = Int(dataLength) - 4
- print("HotlineTrackerClient: About to read \(listingLength) bytes of listing data...")
-
- // Receive listing data
- let listingData = try await socket.readExactly(listingLength)
- print("HotlineTrackerClient: Successfully read \(listingData.count) bytes")
-
- // Parse and yield servers one at a time for true progressive streaming
- var bytes = Array(listingData)
+ // Parse servers directly from socket stream (no buffering!)
let trackerSeparatorRegex = /^[-]+$/
var batchEntriesParsed = 0
var batchServersYielded = 0
for _ in 0..<Int(serverCount2) {
- guard
- let ip1 = bytes.consumeUInt8(),
- let ip2 = bytes.consumeUInt8(),
- let ip3 = bytes.consumeUInt8(),
- let ip4 = bytes.consumeUInt8(),
- let port = bytes.consumeUInt16(),
- let userCount = bytes.consumeUInt16(),
- bytes.consume(2), // Skip unused bytes
- let serverName = bytes.consumePString(),
- let serverDescription = bytes.consumePString()
- else {
- print("HotlineTrackerClient: Insufficient data for entry #\(batchEntriesParsed + 1)")
- break
- }
+ do {
+ // Decode server entry directly from socket stream
+ let server = try await socket.receive(HotlineServer.self)
+
+ batchEntriesParsed += 1
+ totalEntriesParsed += 1
- batchEntriesParsed += 1
- totalEntriesParsed += 1
+ // Filter out separator entries (servers with names like "-------")
+ let isSeparator = server.name.map {
+ (try? trackerSeparatorRegex.prefixMatch(in: $0)) != nil
+ } ?? false
- // Filter out separator entries (servers with names like "-------")
- let isSeparator = (try? trackerSeparatorRegex.prefixMatch(in: serverName)) != nil
- if !isSeparator {
- let server = HotlineServer(
- address: "\(ip1).\(ip2).\(ip3).\(ip4)",
- port: port,
- users: userCount,
- name: serverName,
- description: serverDescription
- )
- continuation.yield(server)
- batchServersYielded += 1
- totalYielded += 1
+ if !isSeparator {
+ // Convert wire format to domain model
+ continuation.yield(server)
+ batchServersYielded += 1
+ totalYielded += 1
+ }
+ } catch {
+ print("HotlineTrackerClient: Failed to decode entry #\(batchEntriesParsed + 1): \(error)")
+ break
}
}
@@ -203,61 +184,4 @@ class HotlineTrackerClient {
return result
}
}
-
- /// Parse tracker listing data into array of servers
- /// - Parameters:
- /// - data: Raw listing data
- /// - serverCount: Expected number of entries (including separators)
- /// - Returns: Tuple of (servers array, total entries parsed including separators)
- /// - Throws: Parsing errors
- private func parseListing(_ data: Data, serverCount: Int) throws -> (servers: [HotlineServer], entriesParsed: Int) {
- // Server record format:
- // - IP address (4 bytes)
- // - Port number (2 bytes)
- // - Number of users (2 bytes)
- // - Unused (2 bytes)
- // - Name size (1 byte)
- // - Name (name size)
- // - Description size (1 byte)
- // - Description (description size)
-
- var bytes = Array(data)
- let trackerSeparatorRegex = /^[-]+$/
- var servers: [HotlineServer] = []
- var entriesParsed = 0
-
- for _ in 0..<serverCount {
- guard
- let ip1 = bytes.consumeUInt8(),
- let ip2 = bytes.consumeUInt8(),
- let ip3 = bytes.consumeUInt8(),
- let ip4 = bytes.consumeUInt8(),
- let port = bytes.consumeUInt16(),
- let userCount = bytes.consumeUInt16(),
- bytes.consume(2), // Skip unused bytes
- let serverName = bytes.consumePString(),
- let serverDescription = bytes.consumePString()
- else {
- print("HotlineTrackerClient: Insufficient data for server #\(entriesParsed + 1)")
- break
- }
-
- entriesParsed += 1
-
- // Filter out separator entries (servers with names like "-------")
- let isSeparator = (try? trackerSeparatorRegex.prefixMatch(in: serverName)) != nil
- if !isSeparator {
- let server = HotlineServer(
- address: "\(ip1).\(ip2).\(ip3).\(ip4)",
- port: port,
- users: userCount,
- name: serverName,
- description: serverDescription
- )
- servers.append(server)
- }
- }
-
- return (servers: servers, entriesParsed: entriesParsed)
- }
}
diff --git a/Hotline/Library/NetSocketNew.swift b/Hotline/Library/NetSocketNew.swift
index 909c44d..753a201 100644
--- a/Hotline/Library/NetSocketNew.swift
+++ b/Hotline/Library/NetSocketNew.swift
@@ -1,6 +1,6 @@
// NetSocketNew.swift
-// Created by Dustin Mierau @mierau
+// Created by Dustin Mierau • @mierau
import Foundation
import Network
@@ -433,6 +433,28 @@ public actor NetSocketNew {
}
try await write(bytes)
}
+
+ /// Write a boolean as a single byte (0 or 1)
+ /// - Parameter value: Boolean value
+ public func write(_ value: Bool) async throws {
+ try await write(value ? UInt8(0x01) : UInt8(0x00))
+ }
+
+ /// Write a Float as its IEEE 754 bit pattern
+ /// - Parameters:
+ /// - value: Float value
+ /// - endian: Byte order (default: big-endian)
+ public func write(_ value: Float, endian: Endian = .big) async throws {
+ 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)
+ public func write(_ value: Double, endian: Endian = .big) async throws {
+ try await write(value.bitPattern, endian: endian)
+ }
/// Write a string to the socket, optionally length-prefixed
///
@@ -511,7 +533,7 @@ public actor NetSocketNew {
length = Int(v)
}
if length > cfg.maxFrameBytes { throw NetSocketError.framingExceeded(max: cfg.maxFrameBytes) }
- return try await readExactly(length)
+ return try await read(length)
}
/// Send an encodable value as a length-prefixed frame
@@ -568,7 +590,7 @@ public actor NetSocketNew {
/// - 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 readExactly(size)
+ let data = try await read(size)
let value: T = data.withUnsafeBytes { raw in
raw.load(as: T.self)
}
@@ -585,8 +607,8 @@ public actor NetSocketNew {
/// - encoding: Text encoding (default: UTF-8)
/// - Returns: Decoded string
/// - Throws: `NetSocketError` if decoding fails or insufficient data
- public func readString(length: Int, encoding: String.Encoding = .utf8) async throws -> String {
- let data = try await readExactly(length)
+ public func read(_ length: Int, encoding: String.Encoding = .utf8) async throws -> String {
+ let data = try await read(length)
guard let s = String(data: data, encoding: encoding) else { throw NetSocketError.decodeFailed(NSError()) }
return s
}
@@ -599,12 +621,57 @@ public actor NetSocketNew {
/// - 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 readString(until delimiter: Delimiter, maxBytes: Int? = nil, includeDelimiter: Bool = false) async throws -> String {
- let bytes = try await readUntil(delimiter: delimiter.data, maxBytes: maxBytes, includeDelimiter: includeDelimiter)
+ 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 a pascal string (1-byte length prefix followed by string data)
+ ///
+ /// This method reads a single byte for the length, then reads that many bytes and attempts
+ /// to decode them as a string. It tries multiple encodings for compatibility with legacy
+ /// protocols like Hotline: UTF-8, Shift-JIS, Windows-1251, and falls back to MacRoman.
+ ///
+ /// - Returns: The decoded string, or nil if length is 0
+ /// - Throws: `NetSocketError` if reading fails or no encoding succeeds
+ public func readPascalString() async throws -> String? {
+ let length = try await read(UInt8.self)
+ guard length > 0 else { return nil }
+
+ let data = try await read(Int(length))
+
+ // Try auto-detection with common encodings
+ let allowedEncodings = [
+ String.Encoding.utf8.rawValue,
+ String.Encoding.shiftJIS.rawValue,
+ String.Encoding.unicode.rawValue,
+ String.Encoding.windowsCP1251.rawValue
+ ]
+
+ var decodedString: NSString?
+ let detected = NSString.stringEncoding(
+ for: data,
+ encodingOptions: [.allowLossyKey: false],
+ convertedString: &decodedString,
+ usedLossyConversion: nil
+ )
+
+ if allowedEncodings.contains(detected), let str = decodedString as? String {
+ return str
+ }
+
+ // Fallback to MacRoman for classic Mac compatibility
+ guard let str = String(data: data, encoding: .macOSRoman) else {
+ throw NetSocketError.decodeFailed(NSError(
+ domain: "NetSocketNew",
+ code: -1,
+ userInfo: [NSLocalizedDescriptionKey: "Failed to decode pascal string with any known encoding"]
+ ))
+ }
+ return str
+ }
+
/// Read data until a delimiter is found
///
/// Searches the buffer for the delimiter pattern and returns all data up to (and optionally including)
@@ -616,12 +683,12 @@ public actor NetSocketNew {
/// - 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 readUntil(delimiter: Data, maxBytes: Int? = nil, includeDelimiter: Bool = false) async throws -> Data {
+ 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 readExactly(consumeLen)
+ let data = try await read(consumeLen)
return includeDelimiter ? data : data.dropLast(delimiter.count)
}
if let maxBytes, availableBytes >= maxBytes {
@@ -640,7 +707,7 @@ public actor NetSocketNew {
/// - Parameter count: Number of bytes to read
/// - Returns: Exactly `count` bytes
/// - Throws: `NetSocketError.insufficientData` if connection closes before enough data arrives
- public func readExactly(_ count: Int) async throws -> Data {
+ public func read(_ count: Int) async throws -> Data {
try await ensureReadable(count)
let start = head
let end = head + count
@@ -659,7 +726,7 @@ public actor NetSocketNew {
}
/// Skip until delimiter is found (discards delimiter too)
- public func skipUntil(delimiter: Data) async throws {
+ public func skip(past delimiter: Data) async throws {
while true {
try Task.checkCancellation()
if let r = search(delimiter: delimiter) {
@@ -887,7 +954,7 @@ public extension NetSocketNew {
while remaining > 0 {
try Task.checkCancellation()
let n = Int(min(Int64(chunkSize), remaining))
- let chunk = try await readExactly(n) // reuses your internal buffer, bounded by n
+ let chunk = try await read(n) // reuses your internal buffer, bounded by n
fh.write(chunk)
remaining -= Int64(n)
written += Int64(n)
@@ -969,7 +1036,7 @@ public extension NetSocketNew {
while remaining > 0 {
try Task.checkCancellation()
let n = Int(min(Int64(chunkSize), remaining))
- let chunk = try await readExactly(n)
+ let chunk = try await read(n)
fh.write(chunk)
remaining -= Int64(n)
written += Int64(n)
@@ -1025,379 +1092,143 @@ fileprivate extension NetSocketNew {
}
}
-/// Errors that can occur during binary encoding/decoding
-public enum BinaryError: Error, CustomStringConvertible, Sendable {
- /// Attempted to read past end of data
- case outOfBounds(need: Int, have: Int)
- /// String encoding/decoding failed
- case badStringEncoding
- /// Invalid value encountered
- case invalidValue(String)
-
- public var description: String {
- switch self {
- case .outOfBounds(let need, let have): return "Out of bounds: need \(need), have \(have)"
- case .badStringEncoding: return "String decoding failed"
- case .invalidValue(let s): return "Invalid value: \(s)"
- }
- }
-}
-
-/// Protocol for types that can encode themselves to binary format
-public protocol BinaryEncodable: Sendable {
- /// Encode this value to a ByteWriter
- /// - Parameters:
- /// - w: Writer to encode to
- /// - endian: Byte order for multi-byte values
- func encode(to w: inout ByteWriter, endian: Endian)
-}
-
-/// Protocol for types that can decode themselves from binary format
-public protocol BinaryDecodable: Sendable {
- /// Decode a value from a ByteReader
- /// - Parameters:
- /// - r: Reader to decode from
- /// - endian: Byte order for multi-byte values
- init(from r: inout ByteReader, endian: Endian) throws
-}
+// MARK: - Stream-based Encoding/Decoding
-/// Convenience typealias for types that are both encodable and decodable
-public typealias BinaryCodable = BinaryEncodable & BinaryDecodable
-
-extension UInt8: BinaryCodable {
- public func encode(to w: inout ByteWriter, endian: Endian) {
- w.write(self, endian: endian)
- }
- public init(from r: inout ByteReader, endian: Endian) throws {
- self = try r.read(UInt8.self, endian: endian)
- }
-}
-
-extension UInt16: BinaryCodable {
- public func encode(to w: inout ByteWriter, endian: Endian) {
- w.write(self, endian: endian)
- }
- public init(from r: inout ByteReader, endian: Endian) throws {
- self = try r.read(UInt16.self, endian: endian)
- }
-}
-
-extension UInt32: BinaryCodable {
- public func encode(to w: inout ByteWriter, endian: Endian) {
- w.write(self, endian: endian)
- }
- public init(from r: inout ByteReader, endian: Endian) throws {
- self = try r.read(UInt32.self, endian: endian)
- }
-}
-
-/// A writer for building binary data structures
+/// Protocol for types that can encode themselves to binary data
///
-/// ByteWriter provides methods to serialize integers, floats, strings, and arrays into binary format.
-/// All multi-byte values are written in the specified endianness.
+/// 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
-/// var w = ByteWriter()
-/// w.write(UInt16(100), endian: .big)
-/// try w.write("Hello", prefix: .u16(.big))
-/// let data = w.finalize()
+/// 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 struct ByteWriter: Sendable {
- /// The accumulated binary data
- public private(set) var data = Data()
-
- public init() {}
-
- /// Write a fixed-width integer
- /// - Parameters:
- /// - v: Integer value
- /// - endian: Byte order (default: big-endian)
- public mutating func write<T: FixedWidthInteger>(_ v: T, endian: Endian = .big) {
- var x = v
- switch endian {
- case .big: x = T(bigEndian: v)
- case .little: x = T(littleEndian: v)
- }
- withUnsafeBytes(of: x) { data.append(contentsOf: $0) }
- }
-
- /// Write a boolean as a single byte (0 or 1)
- /// - Parameter value: Boolean value
- public mutating func write(_ value: Bool) {
- data.append(value ? 1 : 0)
- }
-
- /// Write a Float as its IEEE 754 bit pattern
- /// - Parameters:
- /// - value: Float value
- /// - endian: Byte order (default: big-endian)
- public mutating func write(_ value: Float, endian: Endian = .big) {
- write(value.bitPattern, endian: endian)
- }
-
- /// Write a Double as its IEEE 754 bit pattern
- /// - Parameters:
- /// - value: Double value
- /// - endian: Byte order (default: big-endian)
- public mutating func write(_ value: Double, endian: Endian = .big) {
- write(value.bitPattern, endian: endian)
- }
-
- /// Write raw bytes, optionally with a length prefix
- /// - Parameters:
- /// - bytes: Data to write
- /// - prefix: Optional length prefix
- /// - endian: Byte order for length prefix (ignored if prefix is nil)
- /// - Throws: `BinaryError.invalidValue` if data size exceeds prefix capacity
- public mutating func write(_ bytes: Data, prefix: LengthPrefix? = nil, endian: Endian = .big) throws {
- if let prefix {
- // Validate size fits in prefix type
- switch prefix {
- case .u8 where bytes.count > Int(UInt8.max):
- throw BinaryError.invalidValue("Data size \(bytes.count) exceeds u8 max (255)")
- case .u16 where bytes.count > Int(UInt16.max):
- throw BinaryError.invalidValue("Data size \(bytes.count) exceeds u16 max (65535)")
- case .u32 where bytes.count > Int(UInt32.max):
- throw BinaryError.invalidValue("Data size \(bytes.count) exceeds u32 max")
- default:
- break
- }
-
- switch prefix {
- case .u8: data.append(UInt8(truncatingIfNeeded: bytes.count))
- case .u16(let e): write(UInt16(bytes.count), endian: e)
- case .u32(let e): write(UInt32(bytes.count), endian: e)
- case .u64(let e): write(UInt64(bytes.count), endian: e)
- }
- }
- data.append(bytes)
- }
-
- /// Write a length-prefixed string
- /// - Parameters:
- /// - string: String to write
- /// - prefix: Length prefix type (default: u16 big-endian)
- /// - encoding: Text encoding (default: UTF-8)
- /// - Throws: `BinaryError.badStringEncoding` if encoding fails, or size validation errors
- public mutating func write(_ string: String, prefix: LengthPrefix = .u16(), encoding: String.Encoding = .utf8) throws {
- guard let d = string.data(using: encoding) else {
- throw BinaryError.badStringEncoding
- }
- try write(d, prefix: prefix)
- }
-
- /// Write an array of encodable elements with element count prefix
+public protocol NetSocketEncodable: Sendable {
+ /// Encode this value to binary data
///
- /// The prefix contains the **number of elements**, not the byte size. Each element is
- /// encoded by calling its `encode(to:endian:)` method.
+ /// 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.
///
- /// - Parameters:
- /// - array: Array of elements to write
- /// - prefix: Length prefix for element count (default: u32 big-endian)
- /// - endian: Byte order for multi-byte values within elements
- /// - Throws: `BinaryError.invalidValue` if array count exceeds prefix capacity
- public mutating func write<Element: BinaryEncodable>(_ array: [Element], prefix: LengthPrefix = .u32(), endian: Endian = .big) throws {
- // Validate array count fits in prefix type
- switch prefix {
- case .u8 where array.count > Int(UInt8.max):
- throw BinaryError.invalidValue("Array count \(array.count) exceeds u8 max (255)")
- case .u16 where array.count > Int(UInt16.max):
- throw BinaryError.invalidValue("Array count \(array.count) exceeds u16 max (65535)")
- case .u32 where array.count > Int(UInt32.max):
- throw BinaryError.invalidValue("Array count \(array.count) exceeds u32 max")
- default:
- break
- }
-
- // Number of elements, not byte length:
- switch prefix {
- case .u8: data.append(UInt8(array.count))
- case .u16(let e): write(UInt16(array.count), endian: e)
- case .u32(let e): write(UInt32(array.count), endian: e)
- case .u64(let e): write(UInt64(array.count), endian: e)
- }
- for el in array { el.encode(to: &self, endian: endian) }
- }
-
- /// Get the final binary data
- /// - Returns: All accumulated binary data
- public func finalize() -> Data { data }
+ /// - Parameter endian: Byte order for multi-byte values
+ /// - Returns: Encoded binary data ready to send
+ /// - Throws: Encoding errors
+ func encode(endian: Endian) throws -> Data
}
-/// A reader for parsing binary data structures
+/// Protocol for types that can decode themselves directly from a socket stream
///
-/// ByteReader provides methods to deserialize integers, floats, strings, and arrays from binary format.
-/// The reader maintains an offset that automatically advances as data is read.
+/// 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
-/// var r = ByteReader(data: binaryData)
-/// let value = try r.read(UInt16.self, endian: .big)
-/// let text = try r.readString(prefix: .u16(.big))
+/// struct MyMessage: NetSocketDecodable {
+/// let id: UInt32
+/// let name: String
+///
+/// init(from socket: NetSocketNew, 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 struct ByteReader: Sendable {
- /// The binary data being read
- public let data: Data
- /// Current read position
- public private(set) var offset: Int = 0
-
- public init(data: Data) { self.data = data }
-
- @inline(__always)
- private mutating func take(_ count: Int) throws -> UnsafeRawBufferPointer {
- guard offset + count <= data.count else { throw BinaryError.outOfBounds(need: count, have: data.count - offset) }
- let start = data.startIndex + offset
- offset += count
- return data[start..<(start + count)].withUnsafeBytes { $0 }
- }
-
- /// Read a fixed-width integer
- /// - Parameters:
- /// - type: Integer type to read
- /// - endian: Byte order (default: big-endian)
- /// - Returns: Decoded integer value
- /// - Throws: `BinaryError.outOfBounds` if insufficient data
- public mutating func read<T: FixedWidthInteger>(_ type: T.Type = T.self, endian: Endian = .big) throws -> T {
- let size = MemoryLayout<T>.size
- let raw = try take(size)
- let v = raw.load(as: T.self)
- switch endian {
- case .big: return T(bigEndian: v)
- case .little: return T(littleEndian: v)
- }
- }
-
- /// Read a boolean (0 = false, non-zero = true)
- /// - Returns: Boolean value
- /// - Throws: `BinaryError.outOfBounds` if insufficient data
- public mutating func readBool() throws -> Bool {
- let b: UInt8 = try read()
- return b != 0
- }
-
- /// Read a Float from its IEEE 754 bit pattern
- /// - Parameters:
- /// - type: Float type (for type inference)
- /// - endian: Byte order (default: big-endian)
- /// - Returns: Float value
- /// - Throws: `BinaryError.outOfBounds` if insufficient data
- public mutating func read(_ type: Float.Type = Float.self, endian: Endian = .big) throws -> Float {
- let bits: UInt32 = try read(endian: endian)
- return Float(bitPattern: bits)
- }
-
- /// Read a Double from its IEEE 754 bit pattern
- /// - Parameters:
- /// - type: Double type (for type inference)
- /// - endian: Byte order (default: big-endian)
- /// - Returns: Double value
- /// - Throws: `BinaryError.outOfBounds` if insufficient data
- public mutating func read(_ type: Double.Type = Double.self, endian: Endian = .big) throws -> Double {
- let bits: UInt64 = try read(endian: endian)
- return Double(bitPattern: bits)
- }
-
- /// Read a fixed number of raw bytes
- /// - Parameter count: Number of bytes to read
- /// - Returns: Data containing exactly `count` bytes
- /// - Throws: `BinaryError.outOfBounds` if insufficient data
- public mutating func readData(count: Int) throws -> Data {
- let raw = try take(count)
- return Data(raw)
- }
-
- /// Read length-prefixed raw bytes
- /// - Parameters:
- /// - prefix: Length prefix type
- /// - endian: Byte order for length prefix
- /// - Returns: Data of the specified length
- /// - Throws: `BinaryError` if prefix value is invalid or insufficient data
- public mutating func readData(prefix: LengthPrefix, endian: Endian = .big) throws -> Data {
- let len: Int
- switch prefix {
- case .u8: len = Int(try read(UInt8.self))
- case .u16(let e): len = Int(try read(UInt16.self, endian: e))
- case .u32(let e): len = Int(try read(UInt32.self, endian: e))
- case .u64(let e):
- let v: UInt64 = try read(UInt64.self, endian: e)
- if v > Int.max { throw BinaryError.invalidValue("u64 length too large") }
- len = Int(v)
- }
- return try readData(count: len)
- }
-
- /// Read a length-prefixed string
- /// - Parameters:
- /// - prefix: Length prefix type (default: u16 big-endian)
- /// - encoding: Text encoding (default: UTF-8)
- /// - Returns: Decoded string
- /// - Throws: `BinaryError` if decoding fails or insufficient data
- public mutating func readString(prefix: LengthPrefix = .u16(),
- encoding: String.Encoding = .utf8) throws -> String {
- let d = try readData(prefix: prefix)
- guard let s = String(data: d, encoding: encoding) else { throw BinaryError.badStringEncoding }
- return s
- }
-
- /// Read an array of decodable elements
+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 prefix contains the **number of elements**, not the byte size.
+ /// The socket handles waiting for data to arrive, so you can read field by field
+ /// without worrying about buffering.
///
/// - Parameters:
- /// - type: Element type to decode
- /// - prefix: Length prefix for element count (default: u32 big-endian)
- /// - endian: Byte order for multi-byte values within elements
- /// - Returns: Array of decoded elements
- /// - Throws: `BinaryError` if count is invalid, decoding fails, or insufficient data
- public mutating func readArray<Element: BinaryDecodable>(_ type: Element.Type,
- prefix: LengthPrefix = .u32(),
- endian: Endian = .big) throws -> [Element] {
- let count: Int
- switch prefix {
- case .u8: count = Int(try read(UInt8.self))
- case .u16(let e): count = Int(try read(UInt16.self, endian: e))
- case .u32(let e): count = Int(try read(UInt32.self, endian: e))
- case .u64(let e):
- let v: UInt64 = try read(UInt64.self, endian: e)
- if v > Int.max { throw BinaryError.invalidValue("array count too large") }
- count = Int(v)
- }
- var out: [Element] = []
- out.reserveCapacity(count)
- for _ in 0..<count {
- out.append(try Element(from: &self, endian: endian))
- }
- return out
- }
-
- /// Whether the reader has reached the end of the data
- public var isAtEnd: Bool { offset >= data.count }
+ /// - socket: Socket to read from
+ /// - endian: Byte order for multi-byte values
+ /// - Throws: Network errors, insufficient data, or custom decoding errors
+ init(from socket: NetSocketNew, endian: Endian) async throws
}
public extension NetSocketNew {
- /// Send a binary-encodable value as a length-prefixed frame
+ /// 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 BinaryEncodable
+ /// - value: Value conforming to NetSocketEncodable
/// - endian: Byte order (default: big-endian)
- /// - prefix: Length prefix type (default: u32 big-endian)
/// - Throws: Encoding or network errors
- func sendBinary<T: BinaryEncodable>(_ value: T, endian: Endian = .big, prefix: LengthPrefix = .u32()) async throws {
- var w = ByteWriter()
- value.encode(to: &w, endian: endian)
- try await sendFrame(w.finalize(), prefix: prefix)
+ func send<T: NetSocketEncodable>(_ value: T, endian: Endian = .big) async throws {
+ let data = try value.encode(endian: endian)
+ try await write(data)
}
- /// Receive and decode a binary value from a length-prefixed frame
+ /// 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: NetSocketNew, 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 BinaryDecodable
+ /// - type: Type conforming to NetSocketDecodable
/// - endian: Byte order (default: big-endian)
- /// - prefix: Length prefix type (default: u32 big-endian)
/// - Returns: Decoded value
/// - Throws: Decoding or network errors
- func receiveBinary<T: BinaryDecodable>(_ type: T.Type, endian: Endian = .big, prefix: LengthPrefix = .u32()) async throws -> T {
- let data = try await receiveFrame(prefix: prefix)
- var r = ByteReader(data: data)
- return try T(from: &r, endian: endian)
+ func receive<T: NetSocketDecodable>(_ type: T.Type, endian: Endian = .big) async throws -> T {
+ return try await T(from: self, endian: endian)
}
}