Skip to content

Commit 230c7bf

Browse files
authored
Merge pull request #9 from dustturtle/copilot/optimize-sse-parser-ios
SSE parser byte-level optimization, chunked decoding, auto-reconnect
2 parents 2b690c1 + e7d7b21 commit 230c7bf

5 files changed

Lines changed: 641 additions & 102 deletions

File tree

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import Foundation
2+
3+
/// Incremental decoder for HTTP `Transfer-Encoding: chunked` byte streams.
4+
///
5+
/// When the backend sits behind Nginx, a CDN, or any proxy that applies
6+
/// chunked transfer-encoding, the raw bytes received on the socket are not
7+
/// plain SSE lines but instead follow the HTTP chunked format:
8+
///
9+
/// ```
10+
/// <hex-size>\r\n
11+
/// <data>\r\n
12+
/// ...
13+
/// 0\r\n
14+
/// \r\n
15+
/// ```
16+
///
17+
/// `ChunkedDecoder` strips the framing and yields clean data that can be
18+
/// fed directly into an `SSEParser` or any other stream consumer.
19+
///
20+
/// The decoder is fully incremental: it handles partial chunks that arrive
21+
/// split across multiple TCP segments.
22+
public final class ChunkedDecoder {
23+
24+
// MARK: - State machine
25+
26+
private enum State {
27+
/// Waiting for the hex-length line terminated by `\r\n`.
28+
case waitingForSize
29+
/// Reading `remaining` bytes of chunk data.
30+
case readingData(remaining: Int)
31+
/// Expecting the `\r\n` trailer after a chunk's data.
32+
case readingTrailer
33+
/// The final `0\r\n\r\n` chunk has been received.
34+
case complete
35+
}
36+
37+
// MARK: - Properties
38+
39+
private var state: State = .waitingForSize
40+
private var buffer = Data()
41+
42+
/// Whether the final zero-length chunk has been received.
43+
public var isComplete: Bool {
44+
if case .complete = state { return true }
45+
return false
46+
}
47+
48+
// MARK: - Init
49+
50+
public init() {}
51+
52+
// MARK: - Decode
53+
54+
/// Feed raw bytes from the socket and return decoded (de-chunked) data.
55+
///
56+
/// Any leftover bytes that do not yet form a complete chunk component
57+
/// are buffered internally until the next call.
58+
public func decode(_ data: Data) -> Data {
59+
buffer.append(data)
60+
var output = Data()
61+
62+
loop: while !buffer.isEmpty {
63+
switch state {
64+
case .waitingForSize:
65+
// Look for the CRLF that terminates the size line.
66+
guard let crlfRange = buffer.range(of: Data([0x0D, 0x0A])) else {
67+
break loop // Need more data.
68+
}
69+
let sizeLine = Data(buffer[buffer.startIndex..<crlfRange.lowerBound])
70+
buffer = Data(buffer[crlfRange.upperBound...])
71+
72+
// Parse hex size. Extensions after ';' are allowed by the spec.
73+
guard let sizeStr = String(data: sizeLine, encoding: .ascii) else {
74+
break loop
75+
}
76+
let hexPart = sizeStr.split(separator: ";").first.map(String.init) ?? sizeStr
77+
guard let chunkSize = Int(hexPart.trimmingCharacters(in: .whitespaces), radix: 16) else {
78+
break loop
79+
}
80+
81+
if chunkSize == 0 {
82+
state = .complete
83+
break loop
84+
}
85+
state = .readingData(remaining: chunkSize)
86+
87+
case .readingData(let remaining):
88+
let available = min(remaining, buffer.count)
89+
output.append(buffer.prefix(available))
90+
buffer = Data(buffer.suffix(from: buffer.startIndex + available))
91+
let newRemaining = remaining - available
92+
if newRemaining > 0 {
93+
state = .readingData(remaining: newRemaining)
94+
break loop // Need more data.
95+
}
96+
state = .readingTrailer
97+
98+
case .readingTrailer:
99+
// Each chunk's data is followed by a `\r\n`.
100+
if buffer.count < 2 {
101+
break loop // Need more data.
102+
}
103+
// Skip the trailing CRLF.
104+
if buffer[buffer.startIndex] == 0x0D
105+
&& buffer[buffer.index(after: buffer.startIndex)] == 0x0A {
106+
buffer = Data(buffer.suffix(from: buffer.startIndex + 2))
107+
}
108+
state = .waitingForSize
109+
110+
case .complete:
111+
break loop
112+
}
113+
}
114+
115+
return output
116+
}
117+
118+
// MARK: - Reset
119+
120+
/// Discard all internal state and prepare for a new chunked stream.
121+
public func reset() {
122+
state = .waitingForSize
123+
buffer = Data()
124+
}
125+
}

0 commit comments

Comments
 (0)