Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,16 +65,22 @@ Task {
print("Connection was opened.")
case .error(let error):
print("Received an error:", error.localizedDescription)
case .recived(let data):
if let text = String(data: data, encoding: .utf8) {
print("Recived one stream: \(text)")
} else {
print("Recived one stream: \(data)")
}
case .event(let event):
print("Received an event", event.data ?? "")
case .closed:
case .closed(let undecodeText):
print("Connection was closed.")
}
}
}
```

Use `dataTask.cancel()` to explicitly close the connection. However, in that case `.closed` event won't be emitted.
Use `dataTask.cancel()` to explicitly close the connection.

### Data-only mode

Expand Down
47 changes: 47 additions & 0 deletions Sources/EventSource/Data+Split.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
//
// Data+Split.swift
// EventSource
//
// Created by JadianZheng on 2025/7/24.
//

import Foundation

extension Data {
func split(separators: [[UInt8]]) -> (completeData: [Data], remainingData: Data) {
var currentIndex = startIndex
var messages = [Data]()

while currentIndex < endIndex {
var foundSeparator: [UInt8]? = nil
var foundRange: Range<Data.Index>? = nil

let remainingData = self[currentIndex..<endIndex]

for separator in separators {
if let range = remainingData.firstRange(of: separator) {

if foundRange == nil || range.lowerBound < foundRange!.lowerBound {
foundSeparator = separator
foundRange = range
}
}
}

if let separator = foundSeparator, let range = foundRange {
let messageData = self[currentIndex..<range.lowerBound]

if !messageData.isEmpty {
messages.append(Data(messageData))
}

currentIndex = range.upperBound
} else {
break
}
}

let remainingData = currentIndex < endIndex ? self[currentIndex..<endIndex] : Data()
return (messages, Data(remainingData))
}
}
87 changes: 3 additions & 84 deletions Sources/EventSource/EventParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,15 @@ public protocol EventParser: Sendable {
struct ServerEventParser: EventParser {
private let mode: EventSource.Mode
private var buffer = Data()

var undeocdeText: String? { .init(data: buffer, encoding: .utf8) }

init(mode: EventSource.Mode = .default) {
self.mode = mode
}

static let lf: UInt8 = 0x0A
static let cr: UInt8 = 0x0D
static let colon: UInt8 = 0x3A

mutating func parse(_ data: Data) -> [EVEvent] {
let (separatedMessages, remainingData) = splitBuffer(for: buffer + data)
let (separatedMessages, remainingData) = (buffer + data).split(separators: doubleSeparators)
buffer = remainingData
return parseBuffer(for: separatedMessages)
}
Expand All @@ -37,83 +35,4 @@ struct ServerEventParser: EventParser {

return messages
}

private func splitBuffer(for data: Data) -> (completeData: [Data], remainingData: Data) {
let separators: [[UInt8]] = [[Self.lf, Self.lf], [Self.cr, Self.lf, Self.cr, Self.lf]]

// find last range of our separator, most likely to be fast enough
let (chosenSeparator, lastSeparatorRange) = findLastSeparator(in: data, separators: separators)
guard let separator = chosenSeparator, let lastSeparator = lastSeparatorRange else {
return ([], data)
}

// chop everything before the last separator, going forward, O(n) complexity
let bufferRange = data.startIndex ..< lastSeparator.upperBound
let remainingRange = lastSeparator.upperBound ..< data.endIndex
let rawMessages: [Data] = if #available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, visionOS 1.0, *) {
data[bufferRange].split(separator: separator)
} else {
data[bufferRange].split(by: separator)
}

// now clean up the messages and return
let cleanedMessages = rawMessages.map { cleanMessageData($0) }
return (cleanedMessages, data[remainingRange])
}

private func findLastSeparator(in data: Data, separators: [[UInt8]]) -> ([UInt8]?, Range<Data.Index>?) {
var chosenSeparator: [UInt8]?
var lastSeparatorRange: Range<Data.Index>?
for separator in separators {
if let range = data.lastRange(of: separator) {
if lastSeparatorRange == nil || range.upperBound > lastSeparatorRange!.upperBound {
chosenSeparator = separator
lastSeparatorRange = range
}
}
}
return (chosenSeparator, lastSeparatorRange)
}

private func cleanMessageData(_ messageData: Data) -> Data {
var cleanData = messageData

// remove trailing CR/LF characters from the end
while !cleanData.isEmpty, cleanData.last == Self.cr || cleanData.last == Self.lf {
cleanData = cleanData.dropLast()
}

// also clean internal lines within each message to remove trailing \r
let cleanedLines = cleanData.split(separator: Self.lf)
.map { line in line.trimming(while: { $0 == Self.cr }) }
.joined(separator: [Self.lf])

return Data(cleanedLines)
}
}

fileprivate extension Data {
@available(macOS, deprecated: 13.0, obsoleted: 13.0, message: "This method is not recommended on macOS 13.0+")
@available(iOS, deprecated: 16.0, obsoleted: 16.0, message: "This method is not recommended on iOS 16.0+")
@available(watchOS, deprecated: 9.0, obsoleted: 9.0, message: "This method is not recommended on watchOS 9.0+")
@available(tvOS, deprecated: 16.0, obsoleted: 16.0, message: "This method is not recommended on tvOS 16.0+")
@available(visionOS, deprecated: 1.0, obsoleted: 1.1, message: "This method is not recommended on visionOS 1.0+")
func split(by separator: [UInt8]) -> [Data] {
var chunks: [Data] = []
var pos = startIndex
// Find next occurrence of separator after current position
while let r = self[pos...].range(of: Data(separator)) {
// Append if non-empty
if r.lowerBound > pos {
chunks.append(self[pos..<r.lowerBound])
}
// Update current position
pos = r.upperBound
}
// Append final chunk, if non-empty
if pos < endIndex {
chunks.append(self[pos..<endIndex])
}
return chunks
}
}
21 changes: 7 additions & 14 deletions Sources/EventSource/EventSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,11 @@ public struct EventSource: Sendable {

/// Event type.
public enum EventType: Sendable {
case error(Error)
case event(EVEvent)
case open
case closed
case recived(Data)
case event(EVEvent)
case closed(String?)
case error(Error)
}

private let mode: Mode
Expand Down Expand Up @@ -193,24 +194,16 @@ public extension EventSource {
completionHandler: completionHandler
)
case let .didReceiveData(data):
continuation.yield(.recived(data))
parseMessages(from: data, stream: continuation, urlSession: urlSession)
}
}
}

#if compiler(>=6.0)
continuation.onTermination = { @Sendable [weak self] _ in
sessionDelegateTask.cancel()
Task { self?.close(stream: continuation, urlSession: urlSession) }
}
#else
continuation.onTermination = { @Sendable _ in
sessionDelegateTask.cancel()
Task { [weak self] in
await self?.close(stream: continuation, urlSession: urlSession)
}
self?.close(stream: continuation, urlSession: urlSession)
}
#endif

urlSessionDataTask.resume()
readyState = .connecting
Expand Down Expand Up @@ -277,7 +270,7 @@ public extension EventSource {
private func close(stream continuation: AsyncStream<EventType>.Continuation, urlSession: URLSession) {
let previousState = self.readyState
if previousState != .closed {
continuation.yield(.closed)
continuation.yield(.closed((eventParser as? ServerEventParser)?.undeocdeText))
continuation.finish()
}
cancel(urlSession: urlSession)
Expand Down
50 changes: 50 additions & 0 deletions Sources/EventSource/EventSourceABNF.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
//
// EventSourceEOL.swift
// EventSource
//
// Created by JadianZheng on 2025/7/24.
//

import Foundation

/*
* https://html.spec.whatwg.org/multipage/server-sent-events.html#server-sent-events
*
* Event Stream Format (ABNF):
* stream = [ bom ] *event
* event = *( comment / field ) end-of-line
* comment = colon *any-char end-of-line
* field = 1*name-char [ colon [ space ] *any-char ] end-of-line
* end-of-line = ( cr lf / cr / lf )
*
* ; characters
* lf = %x000A ; U+000A LINE FEED (LF)
* cr = %x000D ; U+000D CARRIAGE RETURN (CR)
* space = %x0020 ; U+0020 SPACE
* colon = %x003A ; U+003A COLON (:)
* bom = %xFEFF ; U+FEFF BYTE ORDER MARK
* name-char = %x0000-0009 / %x000B-000C / %x000E-0039 / %x003B-10FFFF
* ; a scalar value other than U+000A LINE FEED (LF), U+000D CARRIAGE RETURN (CR), or U+003A COLON (:)
* any-char = %x0000-0009 / %x000B-000C / %x000E-10FFFF
* ; a scalar value other than U+000A LINE FEED (LF) or U+000D CARRIAGE RETURN (CR)
*/

let lf: UInt8 = 0x0A // \n
let cr: UInt8 = 0x0D // \r
let colon: UInt8 = 0x3A // :

let singleSeparators: [[UInt8]] = [
[cr, lf], // \r\n
[cr], // \r
[lf] // \n
].sorted { $0.count > $1.count }

let doubleSeparators: [[UInt8]] = [
[cr, lf, cr, lf], // \r\n\r\n
[lf, cr, lf], // \n\r\n
[cr, cr, lf], // \r\r\n
[cr, lf, lf], // \r\n\n
[cr, lf, cr], // \r\n\r
[cr, cr], // \r\r
[lf, lf] // \n\n
].sorted { $0.count > $1.count }
22 changes: 13 additions & 9 deletions Sources/EventSource/ServerEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,22 +68,26 @@ public struct ServerEvent: EVEvent {
}

public static func parse(from data: Data, mode: EventSource.Mode = .default) -> ServerEvent? {
let rows: [Data] = switch mode {
case .default:
data.split(separator: ServerEventParser.lf) // Separate event fields
case .dataOnly:
[data] // Do not split data in data-only mode
}
let rows: [Data] = {
switch mode {
case .default:
let (separatedMessages, remainingData) = data.split(separators: singleSeparators)
return separatedMessages + [remainingData]

case .dataOnly:
return [data] // Do not split data in data-only mode
}
}()

var message = ServerEvent()

for row in rows {
// Skip the line if it is empty or it starts with a colon character
if row.isEmpty || row.first == ServerEventParser.colon {
if row.isEmpty || row.first == colon {
continue
}

let keyValue = row.split(separator: ServerEventParser.colon, maxSplits: 1)
let keyValue = row.split(separator: colon, maxSplits: 1)
let key = keyValue[0].utf8String

// If value starts with a SPACE character, remove it from value
Expand Down Expand Up @@ -111,7 +115,7 @@ public struct ServerEvent: EVEvent {
// If the line is not empty but does not contain a colon character
// add it to the other fields using the whole line as the field name,
// and the empty string as the field value.
if row.contains(ServerEventParser.colon) == false {
if row.contains(colon) == false {
let string = row.utf8String
if var other = message.other {
other[string] = ""
Expand Down
2 changes: 1 addition & 1 deletion Tests/EventSourceTests/EventParserTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ struct EventParserTests {

// Test with mixed LF (\n) and CR+LF (\r\n) - using separate events
let textMixed = "data: test mixedline1\n\n" +
"data: mixedline2\r\n\n" +
"data: mixedline2\n\r\n" +
"event: update\r\ndata: mixedtest\n\n" +
"id: 4\nevent: pong\r\ndata: mixedpong\r\n\n"

Expand Down