diff --git a/README.md b/README.md index 24c265b..42e99c1 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/Sources/EventSource/Data+Split.swift b/Sources/EventSource/Data+Split.swift new file mode 100644 index 0000000..d06e376 --- /dev/null +++ b/Sources/EventSource/Data+Split.swift @@ -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? = nil + + let remainingData = self[currentIndex.. [EVEvent] { - let (separatedMessages, remainingData) = splitBuffer(for: buffer + data) + let (separatedMessages, remainingData) = (buffer + data).split(separators: doubleSeparators) buffer = remainingData return parseBuffer(for: separatedMessages) } @@ -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?) { - var chosenSeparator: [UInt8]? - var lastSeparatorRange: Range? - 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..=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 @@ -277,7 +270,7 @@ public extension EventSource { private func close(stream continuation: AsyncStream.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) diff --git a/Sources/EventSource/EventSourceABNF.swift b/Sources/EventSource/EventSourceABNF.swift new file mode 100644 index 0000000..f72a2bb --- /dev/null +++ b/Sources/EventSource/EventSourceABNF.swift @@ -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 } diff --git a/Sources/EventSource/ServerEvent.swift b/Sources/EventSource/ServerEvent.swift index 51e4893..05e15f4 100644 --- a/Sources/EventSource/ServerEvent.swift +++ b/Sources/EventSource/ServerEvent.swift @@ -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 @@ -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] = "" diff --git a/Tests/EventSourceTests/EventParserTests.swift b/Tests/EventSourceTests/EventParserTests.swift index e737c38..59e2143 100644 --- a/Tests/EventSourceTests/EventParserTests.swift +++ b/Tests/EventSourceTests/EventParserTests.swift @@ -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"