From 4f51c06f4e7db742b3442f21860748e8286f779c Mon Sep 17 00:00:00 2001 From: Firdavs Khaydarov Date: Sat, 10 Feb 2024 23:59:17 +0200 Subject: [PATCH 01/16] Added strict concurrency checks --- Package.swift | 3 ++- Package@swift-5.9.swift | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Package.swift b/Package.swift index 3d90137..d348a6e 100644 --- a/Package.swift +++ b/Package.swift @@ -17,7 +17,8 @@ let package = Package( ], targets: [ .target( - name: "EventSource"), + name: "EventSource", + swiftSettings: [.enableExperimentalFeature("StrictConcurrency")]), .testTarget( name: "EventSourceTests", dependencies: ["EventSource"]), diff --git a/Package@swift-5.9.swift b/Package@swift-5.9.swift index 194c054..53b4a19 100644 --- a/Package@swift-5.9.swift +++ b/Package@swift-5.9.swift @@ -18,7 +18,8 @@ let package = Package( ], targets: [ .target( - name: "EventSource"), + name: "EventSource", + swiftSettings: [.enableExperimentalFeature("StrictConcurrency")]), .testTarget( name: "EventSourceTests", dependencies: ["EventSource"]), From d97445ff3b2149d9d2adfae4463f72ff37149a6d Mon Sep 17 00:00:00 2001 From: Firdavs Khaydarov Date: Sun, 11 Feb 2024 00:02:31 +0200 Subject: [PATCH 02/16] Renamed ServerMessage to ServerEvent, and MessageParser to EventParser --- ...{MessageParser.swift => EventParser.swift} | 11 ++++---- Sources/EventSource/EventSource.swift | 25 ++++++++++--------- ...{ServerMessage.swift => ServerEvent.swift} | 16 ++++++------ ...rserTests.swift => EventParserTests.swift} | 12 ++++----- 4 files changed, 33 insertions(+), 31 deletions(-) rename Sources/EventSource/{MessageParser.swift => EventParser.swift} (87%) rename Sources/EventSource/{ServerMessage.swift => ServerEvent.swift} (87%) rename Tests/EventSourceTests/{MessageParserTests.swift => EventParserTests.swift} (94%) diff --git a/Sources/EventSource/MessageParser.swift b/Sources/EventSource/EventParser.swift similarity index 87% rename from Sources/EventSource/MessageParser.swift rename to Sources/EventSource/EventParser.swift index 2e7cb1b..d0458cd 100644 --- a/Sources/EventSource/MessageParser.swift +++ b/Sources/EventSource/EventParser.swift @@ -1,5 +1,5 @@ // -// MessageParser.swift +// EventParser.swift // EventSource // // Copyright © 2023 Firdavs Khaydarov (Recouse). All rights reserved. @@ -8,11 +8,12 @@ import Foundation -public struct MessageParser { - public var parse: (_ data: Data) -> [ServerMessage] +/// Event parser is used to parse text data into ``ServerEvent``. +public struct EventParser { + public var parse: (_ data: Data) -> [ServerEvent] } -public extension MessageParser { +public extension EventParser { static let lf: UInt8 = 0x0A static let colon: UInt8 = 0x3A @@ -26,7 +27,7 @@ public extension MessageParser { } // Parse data to ServerMessage model - let messages: [ServerMessage] = rawMessages.compactMap(ServerMessage.parse(from:)) + let messages: [ServerEvent] = rawMessages.compactMap(ServerEvent.parse(from:)) return messages }) diff --git a/Sources/EventSource/EventSource.swift b/Sources/EventSource/EventSource.swift index bda50be..cfbcebb 100644 --- a/Sources/EventSource/EventSource.swift +++ b/Sources/EventSource/EventSource.swift @@ -28,27 +28,27 @@ public struct EventSource { /// Event type. public enum EventType { case error(Error) - case message(ServerMessage) + case event(ServerEvent) case open case closed } - private let messageParser: MessageParser + private let eventParser: EventParser public var timeoutInterval: TimeInterval public init( - messageParser: MessageParser = .live, + eventParser: EventParser = .live, timeoutInterval: TimeInterval = 300 ) { - self.messageParser = messageParser + self.eventParser = eventParser self.timeoutInterval = timeoutInterval } public func dataTask(for urlRequest: URLRequest) -> DataTask { DataTask( urlRequest: urlRequest, - messageParser: messageParser, + eventParser: eventParser, timeoutInterval: timeoutInterval ) } @@ -64,7 +64,7 @@ public extension EventSource { /// A string representing the URL of the source. public let urlRequest: URLRequest - private let messageParser: MessageParser + private let eventParser: EventParser private let timeoutInterval: TimeInterval @@ -92,14 +92,15 @@ public extension EventSource { internal init( urlRequest: URLRequest, - messageParser: MessageParser, + eventParser: EventParser, timeoutInterval: TimeInterval ) { self.urlRequest = urlRequest - self.messageParser = messageParser + self.eventParser = eventParser self.timeoutInterval = timeoutInterval } + /// Creates and returns event stream. public func events() -> AsyncStream { AsyncStream { continuation in continuation.onTermination = { @Sendable _ in @@ -194,15 +195,15 @@ public extension EventSource { return } - let messages = messageParser.parse(data) + let events = eventParser.parse(data) // Update last message ID - if let lastMessageWithId = messages.last(where: { $0.id != nil }) { + if let lastMessageWithId = events.last(where: { $0.id != nil }) { lastMessageId = lastMessageWithId.id ?? "" } - messages.forEach { - continuation.yield(.message($0)) + events.forEach { + continuation.yield(.event($0)) } } diff --git a/Sources/EventSource/ServerMessage.swift b/Sources/EventSource/ServerEvent.swift similarity index 87% rename from Sources/EventSource/ServerMessage.swift rename to Sources/EventSource/ServerEvent.swift index 4067146..a78b3ae 100644 --- a/Sources/EventSource/ServerMessage.swift +++ b/Sources/EventSource/ServerEvent.swift @@ -1,5 +1,5 @@ // -// ServerMessage.swift +// ServerEvent.swift // EventSource // // Copyright © 2023 Firdavs Khaydarov (Recouse). All rights reserved. @@ -8,7 +8,7 @@ import Foundation -public struct ServerMessage { +public struct ServerEvent { public var id: String? public var event: String? public var data: String? @@ -53,18 +53,18 @@ public struct ServerMessage { return true } - public static func parse(from data: Data) -> ServerMessage? { - let rows = data.split(separator: MessageParser.lf) // Separate message fields + public static func parse(from data: Data) -> ServerEvent? { + let rows = data.split(separator: EventParser.lf) // Separate message fields - var message = ServerMessage() + 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 == MessageParser.colon { + if row.isEmpty, row.first == EventParser.colon { continue } - let keyValue = row.split(separator: MessageParser.colon, maxSplits: 1) + let keyValue = row.split(separator: EventParser.colon, maxSplits: 1) let key = keyValue[0].utf8String.trimmingCharacters(in: .whitespaces) let value = keyValue[safe: 1]?.utf8String.trimmingCharacters(in: .whitespaces) @@ -85,7 +85,7 @@ public struct ServerMessage { // If the line is not empty but does not contain a color 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(MessageParser.colon) == false { + if row.contains(EventParser.colon) == false { let string = row.utf8String if var other = message.other { other[string] = "" diff --git a/Tests/EventSourceTests/MessageParserTests.swift b/Tests/EventSourceTests/EventParserTests.swift similarity index 94% rename from Tests/EventSourceTests/MessageParserTests.swift rename to Tests/EventSourceTests/EventParserTests.swift index a03acf2..b71c5e4 100644 --- a/Tests/EventSourceTests/MessageParserTests.swift +++ b/Tests/EventSourceTests/EventParserTests.swift @@ -6,9 +6,9 @@ import XCTest @testable import EventSource -final class MessageParserTests: XCTestCase { +final class EventParserTests: XCTestCase { func testMessagesParsing() throws { - let parser = MessageParser.live + let parser = EventParser.live let text = """ data: test 1 @@ -57,7 +57,7 @@ final class MessageParserTests: XCTestCase { } func testEmptyData() { - let parser = MessageParser.live + let parser = EventParser.live let text = """ @@ -71,7 +71,7 @@ final class MessageParserTests: XCTestCase { } func testOtherMessageFormats() { - let parser = MessageParser.live + let parser = EventParser.live let text = """ data : test 1 @@ -124,7 +124,7 @@ final class MessageParserTests: XCTestCase { } func testJSONData() { - let parser = MessageParser.live + let parser = EventParser.live let jsonDecoder = JSONDecoder() let text = """ @@ -150,7 +150,7 @@ final class MessageParserTests: XCTestCase { } } -fileprivate extension MessageParserTests { +fileprivate extension EventParserTests { struct TestModel: Decodable { let id: String let type: String From 6f5009e3de239d7a595ade21f6da2441717a2e7d Mon Sep 17 00:00:00 2001 From: Firdavs Khaydarov Date: Sun, 11 Feb 2024 00:02:45 +0200 Subject: [PATCH 03/16] Added more comments --- Sources/EventSource/EventSource.swift | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/Sources/EventSource/EventSource.swift b/Sources/EventSource/EventSource.swift index cfbcebb..f2c6f32 100644 --- a/Sources/EventSource/EventSource.swift +++ b/Sources/EventSource/EventSource.swift @@ -55,13 +55,21 @@ public struct EventSource { } public extension EventSource { - class DataTask { - /// A number representing the state of the connection. + /// An EventSource task that handles connecting to the URLRequest and creating an event stream. + /// + /// Creation of a task is exclusively handled by ``EventSource``. A new task can be created by calling + /// ``EventSource/EventSource/dataTask(for:)`` method on the EventSource instance. After creating a task, + /// it can be started by iterating event stream returned by ``DataTask/events()``. + final class DataTask { + /// A value representing the state of the connection. public private(set) var readyState: ReadyState = .none + /// Last event's ID string value. + /// + /// Sent in a HTTP request header and used when a user is to reestablish the connection. public private(set) var lastMessageId: String = "" - /// A string representing the URL of the source. + /// A URLRequest of the events source. public let urlRequest: URLRequest private let eventParser: EventParser @@ -218,6 +226,12 @@ public extension EventSource { } } + /// Cancels the task. + /// + /// ## Notes: + /// The event stream supports cooperative task cancellation. However, it should be noted that + /// canceling the parent Task only cancels the underlying `URLSessionDataTask` of + /// ``EventSource/EventSource/DataTask``; this does not actually stop the ongoing request. public func cancel() { readyState = .closed lastMessageId = "" From 1c6ade917d275b87354fb6c6843b00006ff6d891 Mon Sep 17 00:00:00 2001 From: Firdavs Khaydarov Date: Sun, 28 Apr 2024 18:13:06 +0300 Subject: [PATCH 04/16] Made EventParser sendable --- Sources/EventSource/EventParser.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/EventSource/EventParser.swift b/Sources/EventSource/EventParser.swift index d0458cd..3226c02 100644 --- a/Sources/EventSource/EventParser.swift +++ b/Sources/EventSource/EventParser.swift @@ -9,15 +9,15 @@ import Foundation /// Event parser is used to parse text data into ``ServerEvent``. -public struct EventParser { - public var parse: (_ data: Data) -> [ServerEvent] +public struct EventParser: Sendable { + public var parse: @Sendable (_ data: Data) -> [ServerEvent] } public extension EventParser { static let lf: UInt8 = 0x0A static let colon: UInt8 = 0x3A - static let live = Self(parse: { data in + nonisolated(unsafe) static let live = Self(parse: { data in // Split message with double newline let rawMessages: [Data] if #available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, visionOS 1.0, *) { From 000eabe38b26bfb0e81370e6471b125c00e0ded0 Mon Sep 17 00:00:00 2001 From: Firdavs Khaydarov Date: Sun, 4 Aug 2024 15:26:47 +0300 Subject: [PATCH 05/16] Add .nova to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 5922fda..a6192d7 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ DerivedData/ .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata .netrc Package.resolved +.nova/ From 42d826e528d466ff8347c35798dd09623487dd55 Mon Sep 17 00:00:00 2001 From: Firdavs Khaydarov Date: Mon, 7 Oct 2024 01:58:17 +0300 Subject: [PATCH 06/16] Migrated from XCTest to Swift Testing --- Tests/EventSourceTests/EventParserTests.swift | 105 +++++++++--------- 1 file changed, 53 insertions(+), 52 deletions(-) diff --git a/Tests/EventSourceTests/EventParserTests.swift b/Tests/EventSourceTests/EventParserTests.swift index b71c5e4..175a76e 100644 --- a/Tests/EventSourceTests/EventParserTests.swift +++ b/Tests/EventSourceTests/EventParserTests.swift @@ -3,11 +3,12 @@ // Licensed under the MIT License. // -import XCTest +import Foundation +import Testing @testable import EventSource -final class EventParserTests: XCTestCase { - func testMessagesParsing() throws { +struct EventParserTests { + @Test func messagesParsing() throws { let parser = EventParser.live let text = """ @@ -30,33 +31,33 @@ final class EventParserTests: XCTestCase { let messages = parser.parse(data) - XCTAssertEqual(messages.count, 5) + #expect(messages.count == 5) - XCTAssertNotNil(messages[0].data) - XCTAssertEqual(messages[0].data!, "test 1") + #expect(messages[0].data != nil) + #expect(messages[0].data! == "test 1") - XCTAssertNotNil(messages[1].data) - XCTAssertEqual(messages[1].data!, "test 2\ncontinued") + #expect(messages[1].data != nil) + #expect(messages[1].data! == "test 2\ncontinued") - XCTAssertNotNil(messages[2].event) - XCTAssertNotNil(messages[2].data) - XCTAssertEqual(messages[2].event!, "add") - XCTAssertEqual(messages[2].data!, "test 3") + #expect(messages[2].event != nil) + #expect(messages[2].data != nil) + #expect(messages[2].event! == "add") + #expect(messages[2].data! == "test 3") - XCTAssertNotNil(messages[3].event) - XCTAssertNotNil(messages[3].data) - XCTAssertEqual(messages[3].event!, "remove") - XCTAssertEqual(messages[3].data!, "test 4") + #expect(messages[3].event != nil) + #expect(messages[3].data != nil) + #expect(messages[3].event! == "remove") + #expect(messages[3].data! == "test 4") - XCTAssertNotNil(messages[4].id) - XCTAssertNotNil(messages[4].event) - XCTAssertNotNil(messages[4].data) - XCTAssertEqual(messages[4].id!, "5") - XCTAssertEqual(messages[4].event!, "ping") - XCTAssertEqual(messages[4].data!, "test 5") + #expect(messages[4].id != nil) + #expect(messages[4].event != nil) + #expect(messages[4].data != nil) + #expect(messages[4].id! == "5") + #expect(messages[4].event! == "ping") + #expect(messages[4].data! == "test 5") } - func testEmptyData() { + @Test func emptyData() { let parser = EventParser.live let text = """ @@ -67,10 +68,10 @@ final class EventParserTests: XCTestCase { let messages = parser.parse(data) - XCTAssertTrue(messages.isEmpty) + #expect(messages.isEmpty) } - func testOtherMessageFormats() { + @Test func otherMessageFormats() { let parser = EventParser.live let text = """ @@ -95,35 +96,35 @@ final class EventParserTests: XCTestCase { let messages = parser.parse(data) - XCTAssertNotNil(messages[0].data) - XCTAssertEqual(messages[0].data!, "test 1") + #expect(messages[0].data != nil) + #expect(messages[0].data! == "test 1") - XCTAssertNotNil(messages[1].id) - XCTAssertNotNil(messages[1].data) - XCTAssertEqual(messages[1].id!, "2") - XCTAssertEqual(messages[1].data!, "test 2") + #expect(messages[1].id != nil) + #expect(messages[1].data != nil) + #expect(messages[1].id! == "2") + #expect(messages[1].data! == "test 2") - XCTAssertNotNil(messages[2].event) - XCTAssertNotNil(messages[2].data) - XCTAssertEqual(messages[2].event!, "add") - XCTAssertEqual(messages[2].data!, "test 3") + #expect(messages[2].event != nil) + #expect(messages[2].data != nil) + #expect(messages[2].event! == "add") + #expect(messages[2].data! == "test 3") - XCTAssertNotNil(messages[3].id) - XCTAssertNotNil(messages[3].event) - XCTAssertNotNil(messages[3].data) - XCTAssertEqual(messages[3].id!, "4") - XCTAssertEqual(messages[3].event!, "ping") - XCTAssertEqual(messages[3].data!, "test 4") + #expect(messages[3].id != nil) + #expect(messages[3].event != nil) + #expect(messages[3].data != nil) + #expect(messages[3].id! == "4") + #expect(messages[3].event! == "ping") + #expect(messages[3].data! == "test 4") - XCTAssertNotNil(messages[4].other) - XCTAssertEqual(messages[4].other!["test 5"], "") + #expect(messages[4].other != nil) + #expect(messages[4].other!["test 5"] == "") - XCTAssertNotNil(messages[5].other) - XCTAssertEqual(messages[5].other!["message 6"], "") - XCTAssertEqual(messages[5].other!["message 6-1"], "") + #expect(messages[5].other != nil) + #expect(messages[5].other!["message 6"] == "") + #expect(messages[5].other!["message 6-1"] == "") } - func testJSONData() { + @Test func jsonData() { let parser = EventParser.live let jsonDecoder = JSONDecoder() @@ -138,14 +139,14 @@ final class EventParserTests: XCTestCase { let messages = parser.parse(data) - XCTAssertNotNil(messages[0].data) - XCTAssertNotNil(messages[1].data) + #expect(messages[0].data != nil) + #expect(messages[1].data != nil) do { - let decoded1 = try jsonDecoder.decode(TestModel.self, from: Data(messages[0].data!.utf8)) - let decoded2 = try jsonDecoder.decode(TestModel.self, from: Data(messages[1].data!.utf8)) + let _ = try jsonDecoder.decode(TestModel.self, from: Data(messages[0].data!.utf8)) + let _ = try jsonDecoder.decode(TestModel.self, from: Data(messages[1].data!.utf8)) } catch { - XCTFail("The JSON strings provided in the test data were parsed incorrectly.") + Issue.record("The JSON strings provided in the test data were parsed incorrectly.") } } } From 167f101e80afa285ca238587cc463f08edd65291 Mon Sep 17 00:00:00 2001 From: Firdavs Khaydarov Date: Tue, 8 Oct 2024 00:11:10 +0300 Subject: [PATCH 07/16] Updated github actions workflows --- .github/workflows/linux.yml | 2 +- .github/workflows/macos.yml | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index 0899a5f..67a622f 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -13,7 +13,7 @@ jobs: container: image: swift:latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Build run: swift build -v - name: Run tests diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index 1da40e1..98e74c9 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -9,12 +9,10 @@ on: jobs: build: name: Build macOS - runs-on: macos-14 + runs-on: macos-15 steps: - name: Checkout uses: actions/checkout@v4 - - name: Set up Xcode version - run: sudo xcode-select -s /Applications/Xcode_15.3.app - name: Build run: swift build -v - name: Run tests From 1b623add6acde7a0f6487bb0256a9b6ca45509c3 Mon Sep 17 00:00:00 2001 From: Firdavs Khaydarov Date: Sun, 17 Nov 2024 15:52:51 +0200 Subject: [PATCH 08/16] Added .vscode to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index a6192d7..d78d4cd 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ DerivedData/ .netrc Package.resolved .nova/ +.vscode From 74070d9d54a9f476442fd5d756e63af599bf1798 Mon Sep 17 00:00:00 2001 From: Firdavs Khaydarov Date: Sun, 17 Nov 2024 15:56:00 +0200 Subject: [PATCH 09/16] Introduced EVEvent protocol and allowed custom events implementation --- Sources/EventSource/EventParser.swift | 4 +- Sources/EventSource/EventSource.swift | 2 +- Sources/EventSource/ServerEvent.swift | 66 ++++++++++++++++----------- 3 files changed, 43 insertions(+), 29 deletions(-) diff --git a/Sources/EventSource/EventParser.swift b/Sources/EventSource/EventParser.swift index 3226c02..a21b757 100644 --- a/Sources/EventSource/EventParser.swift +++ b/Sources/EventSource/EventParser.swift @@ -10,14 +10,14 @@ import Foundation /// Event parser is used to parse text data into ``ServerEvent``. public struct EventParser: Sendable { - public var parse: @Sendable (_ data: Data) -> [ServerEvent] + public var parse: @Sendable (_ data: Data) -> [EVEvent] } public extension EventParser { static let lf: UInt8 = 0x0A static let colon: UInt8 = 0x3A - nonisolated(unsafe) static let live = Self(parse: { data in + static let live = Self(parse: { data in // Split message with double newline let rawMessages: [Data] if #available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, visionOS 1.0, *) { diff --git a/Sources/EventSource/EventSource.swift b/Sources/EventSource/EventSource.swift index 62f787b..1e39e70 100644 --- a/Sources/EventSource/EventSource.swift +++ b/Sources/EventSource/EventSource.swift @@ -28,7 +28,7 @@ public struct EventSource { /// Event type. public enum EventType { case error(Error) - case event(ServerEvent) + case event(EVEvent) case open case closed } diff --git a/Sources/EventSource/ServerEvent.swift b/Sources/EventSource/ServerEvent.swift index a78b3ae..90b3bf5 100644 --- a/Sources/EventSource/ServerEvent.swift +++ b/Sources/EventSource/ServerEvent.swift @@ -8,7 +8,45 @@ import Foundation -public struct ServerEvent { +/// Protocol for defining a basic event structure. It is used by the ``EventParser`` +/// and should be implemented as a custom type when a custom ``EventParser`` is required. +public protocol EVEvent { + var id: String? { get } + var event: String? { get } + var data: String? { get } + var other: [String: String]? { get } + var time: String? { get } +} + +public extension EVEvent { + /// Checks if all event fields are empty. + var isEmpty: Bool { + if let id, !id.isEmpty { + return false + } + + if let event, !event.isEmpty { + return false + } + + if let data, !data.isEmpty { + return false + } + + if let other, !other.isEmpty { + return false + } + + if let time, !time.isEmpty { + return false + } + + return true + } +} + +/// Default implementation of ``EventSourceEvent`` used in the package. +public struct ServerEvent: EVEvent { public var id: String? public var event: String? public var data: String? @@ -29,30 +67,6 @@ public struct ServerEvent { self.time = time } - private func isEmpty() -> Bool { - if let id, !id.isEmpty { - return false - } - - if let event, !event.isEmpty { - return false - } - - if let data, !data.isEmpty { - return false - } - - if let other, !other.isEmpty { - return false - } - - if let time, !time.isEmpty { - return false - } - - return true - } - public static func parse(from data: Data) -> ServerEvent? { let rows = data.split(separator: EventParser.lf) // Separate message fields @@ -97,7 +111,7 @@ public struct ServerEvent { } } - if message.isEmpty() { + if message.isEmpty { return nil } From 8d283600bcfdba8fb22041d448fe9baf144ecca3 Mon Sep 17 00:00:00 2001 From: Firdavs Khaydarov Date: Sun, 17 Nov 2024 16:04:09 +0200 Subject: [PATCH 10/16] Updated README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c658be3..d29d6c9 100644 --- a/README.md +++ b/README.md @@ -65,8 +65,8 @@ for await event in dataTask.events() { print("Connection was opened.") case .error(let error): print("Received an error:", error.localizedDescription) - case .message(let message): - print("Received a message", message.data ?? "") + case .event(let event): + print("Received an event", event.data ?? "") case .closed: print("Connection was closed.") } From 1567f7c9adb65e43872229cd9de3487e44f07f41 Mon Sep 17 00:00:00 2001 From: Firdavs Khaydarov Date: Sun, 17 Nov 2024 20:58:04 +0200 Subject: [PATCH 11/16] Introduced EventSourceActor, fixed all concurrency warnings --- README.md | 4 +- Sources/EventSource/EventSource.swift | 60 +++++++++++++---------- Sources/EventSource/ServerEvent.swift | 2 +- Sources/EventSource/SessionDelegate.swift | 20 ++++---- 4 files changed, 47 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index d29d6c9..ef62168 100644 --- a/README.md +++ b/README.md @@ -57,9 +57,9 @@ Using EventSource is easy. Simply create a new task from an instance of EventSou import EventSource let eventSource = EventSource() -let dataTask = eventSource.dataTask(for: urlRequest) +let dataTask = await eventSource.dataTask(for: urlRequest) -for await event in dataTask.events() { +for await event in await dataTask.events() { switch event { case .open: print("Connection was opened.") diff --git a/Sources/EventSource/EventSource.swift b/Sources/EventSource/EventSource.swift index 1e39e70..64b7fd0 100644 --- a/Sources/EventSource/EventSource.swift +++ b/Sources/EventSource/EventSource.swift @@ -11,6 +11,11 @@ import Foundation import FoundationNetworking #endif +/// The global actor used for isolating ``EventSource/EventSource/DataTask``. +@globalActor public actor EventSourceActor: GlobalActor { + public static let shared = EventSourceActor() +} + /// /// An `EventSource` instance opens a persistent connection to an HTTP server, /// which sends events in `text/event-stream` format. @@ -26,7 +31,7 @@ public struct EventSource { } /// Event type. - public enum EventType { + public enum EventType: Sendable { case error(Error) case event(EVEvent) case open @@ -44,7 +49,8 @@ public struct EventSource { self.eventParser = eventParser self.timeoutInterval = timeoutInterval } - + + @EventSourceActor public func dataTask(for urlRequest: URLRequest) -> DataTask { DataTask( urlRequest: urlRequest, @@ -60,7 +66,7 @@ public extension EventSource { /// Creation of a task is exclusively handled by ``EventSource``. A new task can be created by calling /// ``EventSource/EventSource/dataTask(for:)`` method on the EventSource instance. After creating a task, /// it can be started by iterating event stream returned by ``DataTask/events()``. - final class DataTask { + @EventSourceActor final class DataTask { /// A value representing the state of the connection. public private(set) var readyState: ReadyState = .none @@ -79,11 +85,7 @@ public extension EventSource { private var continuation: AsyncStream.Continuation? private var urlSession: URLSession? - - private var sessionDelegate = SessionDelegate() - - private var sessionDelegateTask: Task? - + private var urlSessionDataTask: URLSessionDataTask? private var httpResponseErrorStatusCode: Int? @@ -113,31 +115,36 @@ public extension EventSource { /// Creates and returns event stream. public func events() -> AsyncStream { AsyncStream { continuation in + let sessionDelegate = SessionDelegate() + let sesstionDelegateTask = Task { [weak self] in + for await event in sessionDelegate.eventStream { + guard let self else { return } + + switch event { + case let .didCompleteWithError(error): + handleSessionError(error) + case let .didReceiveResponse(response, completionHandler): + handleSessionResponse(response, completionHandler: completionHandler) + case let .didReceiveData(data): + parseMessages(from: data) + } + } + } + continuation.onTermination = { @Sendable [weak self] _ in - self?.close() + sesstionDelegateTask.cancel() + Task { await self?.close() } } - + self.continuation = continuation - + + urlSession = URLSession( configuration: urlSessionConfiguration, delegate: sessionDelegate, delegateQueue: nil ) - - sessionDelegate.onEvent = { [weak self] event in - guard let self else { return } - - switch event { - case let .didCompleteWithError(error): - handleSessionError(error) - case let .didReceiveResponse(response, completionHandler): - handleSessionResponse(response, completionHandler: completionHandler) - case let .didReceiveData(data): - parseMessages(from: data) - } - } - + urlSessionDataTask = urlSession!.dataTask(with: urlRequest) urlSessionDataTask!.resume() readyState = .connecting @@ -194,7 +201,7 @@ public extension EventSource { /// Closes the connection, if one was made, /// and sets the `readyState` property to `.closed`. /// - Returns: State before closing. - @Sendable private func close() { + private func close() { let previousState = self.readyState if previousState != .closed { continuation?.yield(.closed) @@ -242,7 +249,6 @@ public extension EventSource { public func cancel() { readyState = .closed lastMessageId = "" - sessionDelegateTask?.cancel() urlSessionDataTask?.cancel() urlSession?.invalidateAndCancel() } diff --git a/Sources/EventSource/ServerEvent.swift b/Sources/EventSource/ServerEvent.swift index 90b3bf5..88a04a5 100644 --- a/Sources/EventSource/ServerEvent.swift +++ b/Sources/EventSource/ServerEvent.swift @@ -10,7 +10,7 @@ import Foundation /// Protocol for defining a basic event structure. It is used by the ``EventParser`` /// and should be implemented as a custom type when a custom ``EventParser`` is required. -public protocol EVEvent { +public protocol EVEvent: Sendable { var id: String? { get } var event: String? { get } var data: String? { get } diff --git a/Sources/EventSource/SessionDelegate.swift b/Sources/EventSource/SessionDelegate.swift index fbe1ed2..be55a5a 100644 --- a/Sources/EventSource/SessionDelegate.swift +++ b/Sources/EventSource/SessionDelegate.swift @@ -12,29 +12,31 @@ import Foundation #endif final class SessionDelegate: NSObject, URLSessionDataDelegate { - enum Event { + enum Event: Sendable { case didCompleteWithError(Error?) - case didReceiveResponse(URLResponse, (URLSession.ResponseDisposition) -> Void) + case didReceiveResponse(URLResponse, @Sendable (URLSession.ResponseDisposition) -> Void) case didReceiveData(Data) } - - var onEvent: (Event) -> Void = { _ in } - + + private let internalStream = AsyncStream.makeStream() + + var eventStream: AsyncStream { internalStream.stream } + func urlSession( _ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error? ) { - onEvent(.didCompleteWithError(error)) + internalStream.continuation.yield(.didCompleteWithError(error)) } func urlSession( _ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, - completionHandler: @escaping (URLSession.ResponseDisposition) -> Void + completionHandler: @Sendable @escaping (URLSession.ResponseDisposition) -> Void ) { - onEvent(.didReceiveResponse(response, completionHandler)) + internalStream.continuation.yield(.didReceiveResponse(response, completionHandler)) } func urlSession( @@ -42,6 +44,6 @@ final class SessionDelegate: NSObject, URLSessionDataDelegate { dataTask: URLSessionDataTask, didReceive data: Data ) { - onEvent(.didReceiveData(data)) + internalStream.continuation.yield(.didReceiveData(data)) } } From a649feb15c44b0b45786b343270b39173210a1af Mon Sep 17 00:00:00 2001 From: Firdavs Khaydarov Date: Sun, 17 Nov 2024 23:03:44 +0200 Subject: [PATCH 12/16] Made EventSource sendable --- Sources/EventSource/EventSource.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/EventSource/EventSource.swift b/Sources/EventSource/EventSource.swift index 64b7fd0..4d7b033 100644 --- a/Sources/EventSource/EventSource.swift +++ b/Sources/EventSource/EventSource.swift @@ -21,7 +21,7 @@ import Foundation /// which sends events in `text/event-stream` format. /// The connection remains open until closed by calling `close()`. /// -public struct EventSource { +public struct EventSource: Sendable { /// State of the connection. public enum ReadyState: Int { case none = -1 @@ -101,7 +101,7 @@ public extension EventSource { configuration.timeoutIntervalForResource = self.timeoutInterval return configuration } - + internal init( urlRequest: URLRequest, eventParser: EventParser, @@ -111,7 +111,7 @@ public extension EventSource { self.eventParser = eventParser self.timeoutInterval = timeoutInterval } - + /// Creates and returns event stream. public func events() -> AsyncStream { AsyncStream { continuation in From a6585316925015f61b289fedbf3b1c36f453b967 Mon Sep 17 00:00:00 2001 From: Firdavs Khaydarov Date: Sun, 17 Nov 2024 23:32:42 +0200 Subject: [PATCH 13/16] Removed extra whitespace trimming in event parser --- Sources/EventSource/EventParser.swift | 2 +- Sources/EventSource/ServerEvent.swift | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Sources/EventSource/EventParser.swift b/Sources/EventSource/EventParser.swift index a21b757..d78bbef 100644 --- a/Sources/EventSource/EventParser.swift +++ b/Sources/EventSource/EventParser.swift @@ -16,7 +16,7 @@ public struct EventParser: Sendable { public extension EventParser { static let lf: UInt8 = 0x0A static let colon: UInt8 = 0x3A - + static let live = Self(parse: { data in // Split message with double newline let rawMessages: [Data] diff --git a/Sources/EventSource/ServerEvent.swift b/Sources/EventSource/ServerEvent.swift index 88a04a5..6484439 100644 --- a/Sources/EventSource/ServerEvent.swift +++ b/Sources/EventSource/ServerEvent.swift @@ -84,17 +84,17 @@ public struct ServerEvent: EVEvent { switch key { case "id": - message.id = value?.trimmingCharacters(in: .whitespaces) + message.id = value case "event": - message.event = value?.trimmingCharacters(in: .whitespaces) + message.event = value case "data": if let existingData = message.data { - message.data = existingData + "\n" + (value?.trimmingCharacters(in: .whitespaces) ?? "") + message.data = existingData + "\n" + (value ?? "") } else { - message.data = value?.trimmingCharacters(in: .whitespaces) + message.data = value } case "time": - message.time = value?.trimmingCharacters(in: .whitespaces) + message.time = value default: // If the line is not empty but does not contain a color character // add it to the other fields using the whole line as the field name, From a3ca65213152a9e7f0d97a93ee715fb251abf5f4 Mon Sep 17 00:00:00 2001 From: Firdavs Khaydarov Date: Mon, 18 Nov 2024 15:51:18 +0200 Subject: [PATCH 14/16] Prevent creation of a new stream for an already consumed DataTask --- Sources/EventSource/EventSource.swift | 9 ++++++++- Sources/EventSource/EventSourceError.swift | 16 +++++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/Sources/EventSource/EventSource.swift b/Sources/EventSource/EventSource.swift index 4d7b033..603f993 100644 --- a/Sources/EventSource/EventSource.swift +++ b/Sources/EventSource/EventSource.swift @@ -114,7 +114,14 @@ public extension EventSource { /// Creates and returns event stream. public func events() -> AsyncStream { - AsyncStream { continuation in + if urlSessionDataTask != nil { + return AsyncStream { continuation in + continuation.yield(.error(EventSourceError.alreadyConsumed)) + continuation.finish() + } + } + + return AsyncStream { continuation in let sessionDelegate = SessionDelegate() let sesstionDelegateTask = Task { [weak self] in for await event in sessionDelegate.eventStream { diff --git a/Sources/EventSource/EventSourceError.swift b/Sources/EventSource/EventSourceError.swift index c8d9735..f936c86 100644 --- a/Sources/EventSource/EventSourceError.swift +++ b/Sources/EventSource/EventSourceError.swift @@ -8,7 +8,21 @@ import Foundation -public enum EventSourceError: Error { +public enum EventSourceError: LocalizedError { case undefinedConnectionError + case connectionError(statusCode: Int, response: Data) + + /// The ``EventSource/EventSource/DataTask`` event stream is already being consumed by another task. + /// A stream can only be consumed by one task at a time. + case alreadyConsumed + + public var errorDescription: String? { + switch self { + case .alreadyConsumed: + "The `DataTask` events stream is already being consumed by another task." + default: + nil + } + } } From c5023899311ef2feb6badd6b1c6883bc9defdf18 Mon Sep 17 00:00:00 2001 From: Firdavs Khaydarov Date: Tue, 19 Nov 2024 23:56:17 +0200 Subject: [PATCH 15/16] Added EventSource.Mode for data-only events --- Sources/EventSource/EventParser.swift | 24 +++++---- Sources/EventSource/EventSource.swift | 49 ++++++++++++------- Sources/EventSource/ServerEvent.swift | 19 ++++--- Tests/EventSourceTests/EventParserTests.swift | 48 +++++++++--------- 4 files changed, 81 insertions(+), 59 deletions(-) diff --git a/Sources/EventSource/EventParser.swift b/Sources/EventSource/EventParser.swift index d78bbef..f172db0 100644 --- a/Sources/EventSource/EventParser.swift +++ b/Sources/EventSource/EventParser.swift @@ -8,16 +8,22 @@ import Foundation -/// Event parser is used to parse text data into ``ServerEvent``. -public struct EventParser: Sendable { - public var parse: @Sendable (_ data: Data) -> [EVEvent] +public protocol EventParser: Sendable { + func parse(_ data: Data) -> [EVEvent] } -public extension EventParser { +/// ``ServerEventParser`` is used to parse text data into ``ServerEvent``. +public struct ServerEventParser: EventParser { + let mode: EventSource.Mode + + init(mode: EventSource.Mode = .default) { + self.mode = mode + } + static let lf: UInt8 = 0x0A static let colon: UInt8 = 0x3A - static let live = Self(parse: { data in + public func parse(_ data: Data) -> [EVEvent] { // Split message with double newline let rawMessages: [Data] if #available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, visionOS 1.0, *) { @@ -25,12 +31,12 @@ public extension EventParser { } else { rawMessages = data.split(by: [Self.lf, Self.lf]) } - + // Parse data to ServerMessage model - let messages: [ServerEvent] = rawMessages.compactMap(ServerEvent.parse(from:)) - + let messages: [ServerEvent] = rawMessages.compactMap { ServerEvent.parse(from: $0, mode: mode) } + return messages - }) + } } fileprivate extension Data { diff --git a/Sources/EventSource/EventSource.swift b/Sources/EventSource/EventSource.swift index 603f993..fb6cffd 100644 --- a/Sources/EventSource/EventSource.swift +++ b/Sources/EventSource/EventSource.swift @@ -22,6 +22,11 @@ import Foundation /// The connection remains open until closed by calling `close()`. /// public struct EventSource: Sendable { + public enum Mode: Sendable { + case `default` + case dataOnly + } + /// State of the connection. public enum ReadyState: Int { case none = -1 @@ -37,16 +42,24 @@ public struct EventSource: Sendable { case open case closed } - + + private let mode: Mode + private let eventParser: EventParser public var timeoutInterval: TimeInterval public init( - eventParser: EventParser = .live, + mode: Mode = .default, + eventParser: EventParser? = nil, timeoutInterval: TimeInterval = 300 ) { - self.eventParser = eventParser + self.mode = mode + if let eventParser { + self.eventParser = eventParser + } else { + self.eventParser = ServerEventParser(mode: mode) + } self.timeoutInterval = timeoutInterval } @@ -69,27 +82,27 @@ public extension EventSource { @EventSourceActor final class DataTask { /// A value representing the state of the connection. public private(set) var readyState: ReadyState = .none - + /// Last event's ID string value. /// /// Sent in a HTTP request header and used when a user is to reestablish the connection. public private(set) var lastMessageId: String = "" - + /// A URLRequest of the events source. public let urlRequest: URLRequest - + private let eventParser: EventParser - + private let timeoutInterval: TimeInterval - + private var continuation: AsyncStream.Continuation? - + private var urlSession: URLSession? private var urlSessionDataTask: URLSessionDataTask? - + private var httpResponseErrorStatusCode: Int? - + private var urlSessionConfiguration: URLSessionConfiguration { let configuration = URLSessionConfiguration.default configuration.httpAdditionalHeaders = [ @@ -157,7 +170,7 @@ public extension EventSource { readyState = .connecting } } - + private func handleSessionError(_ error: Error?) { guard readyState != .closed else { close() @@ -172,7 +185,7 @@ public extension EventSource { // Close connection close() } - + private func handleSessionResponse( _ response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void @@ -204,7 +217,7 @@ public extension EventSource { completionHandler(.allow) } - + /// Closes the connection, if one was made, /// and sets the `readyState` property to `.closed`. /// - Returns: State before closing. @@ -216,7 +229,7 @@ public extension EventSource { } cancel() } - + private func parseMessages(from data: Data) { if let httpResponseErrorStatusCode { self.httpResponseErrorStatusCode = nil @@ -227,7 +240,7 @@ public extension EventSource { } let events = eventParser.parse(data) - + // Update last message ID if let lastMessageWithId = events.last(where: { $0.id != nil }) { lastMessageId = lastMessageWithId.id ?? "" @@ -242,11 +255,11 @@ public extension EventSource { readyState = .open continuation?.yield(.open) } - + private func sendErrorEvent(with error: Error) { continuation?.yield(.error(error)) } - + /// Cancels the task. /// /// ## Notes: diff --git a/Sources/EventSource/ServerEvent.swift b/Sources/EventSource/ServerEvent.swift index 6484439..bfecce8 100644 --- a/Sources/EventSource/ServerEvent.swift +++ b/Sources/EventSource/ServerEvent.swift @@ -67,18 +67,23 @@ public struct ServerEvent: EVEvent { self.time = time } - public static func parse(from data: Data) -> ServerEvent? { - let rows = data.split(separator: EventParser.lf) // Separate message fields - + 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 + } + 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 == EventParser.colon { + if row.isEmpty, row.first == ServerEventParser.colon { continue } - let keyValue = row.split(separator: EventParser.colon, maxSplits: 1) + let keyValue = row.split(separator: ServerEventParser.colon, maxSplits: 1) let key = keyValue[0].utf8String.trimmingCharacters(in: .whitespaces) let value = keyValue[safe: 1]?.utf8String.trimmingCharacters(in: .whitespaces) @@ -96,10 +101,10 @@ public struct ServerEvent: EVEvent { case "time": message.time = value default: - // If the line is not empty but does not contain a color character + // 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(EventParser.colon) == false { + if row.contains(ServerEventParser.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 175a76e..68285a5 100644 --- a/Tests/EventSourceTests/EventParserTests.swift +++ b/Tests/EventSourceTests/EventParserTests.swift @@ -9,7 +9,7 @@ import Testing struct EventParserTests { @Test func messagesParsing() throws { - let parser = EventParser.live + let parser = ServerEventParser() let text = """ data: test 1 @@ -30,7 +30,7 @@ struct EventParserTests { let data = Data(text.utf8) let messages = parser.parse(data) - + #expect(messages.count == 5) #expect(messages[0].data != nil) @@ -58,8 +58,8 @@ struct EventParserTests { } @Test func emptyData() { - let parser = EventParser.live - + let parser = ServerEventParser() + let text = """ @@ -67,13 +67,13 @@ struct EventParserTests { let data = Data(text.utf8) let messages = parser.parse(data) - + #expect(messages.isEmpty) } @Test func otherMessageFormats() { - let parser = EventParser.live - + let parser = ServerEventParser() + let text = """ data : test 1 @@ -123,31 +123,29 @@ struct EventParserTests { #expect(messages[5].other!["message 6"] == "") #expect(messages[5].other!["message 6-1"] == "") } - - @Test func jsonData() { - let parser = EventParser.live + + @Test func dataOnlyMode() throws { + let parser = ServerEventParser(mode: .dataOnly) let jsonDecoder = JSONDecoder() - + let text = """ - data: {\"id\":\"abcd-1\",\"type\":\"message\",\"content\":\"\\ntest\\n\"} + data: {"id":"abcd-1","type":"message","content":"\\ntest\\n"} - id: abcd-2 - data: {\"id\":\"abcd-2\",\"type\":\"message\",\"content\":\"\\n\\n"} + data: {"id":"abcd-2","type":"message","content":"\\n\\n"} """ let data = Data(text.utf8) - + let messages = parser.parse(data) - - #expect(messages[0].data != nil) - #expect(messages[1].data != nil) - - do { - let _ = try jsonDecoder.decode(TestModel.self, from: Data(messages[0].data!.utf8)) - let _ = try jsonDecoder.decode(TestModel.self, from: Data(messages[1].data!.utf8)) - } catch { - Issue.record("The JSON strings provided in the test data were parsed incorrectly.") - } + + let data1 = Data(try #require(messages[0].data?.utf8)) + let data2 = Data(try #require(messages[1].data?.utf8)) + + let message1 = try jsonDecoder.decode(TestModel.self, from: data1) + let message2 = try jsonDecoder.decode(TestModel.self, from: data2) + + #expect(message1.content == "\ntest\n") + #expect(message2.content == "\n\n") } } From 035bee012ab0e3b3d76196bbf94420e38959be76 Mon Sep 17 00:00:00 2001 From: Firdavs Khaydarov Date: Wed, 20 Nov 2024 00:46:11 +0200 Subject: [PATCH 16/16] Updated README.md --- README.md | 63 +++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 52 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index ef62168..53d74f9 100644 --- a/README.md +++ b/README.md @@ -5,22 +5,23 @@ [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FRecouse%2FEventSource%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/Recouse/EventSource) [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FRecouse%2FEventSource%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/Recouse/EventSource) -EventSource is a Swift package that provides a simple implementation of a client for [Server-Sent -Events](https://html.spec.whatwg.org/multipage/server-sent-events.html) (SSE). It allows you to easily -receive real-time updates from a server over a persistent HTTP connection, using a simple and efficient -interface. +EventSource is a Swift package that provides a simple implementation of a client for [Server-Sent Events](https://html.spec.whatwg.org/multipage/server-sent-events.html) (SSE). It allows you to easily receive real-time updates from a server over a persistent HTTP connection, using a simple and efficient interface. It also leverages Swift concurrency features to provide a more expressive and intuitive way to handle asynchronous operations. > [!Note] -> Please note that this package was originally developed to be used in conjunction with another package, -and as such, it may not cover all specification details. Please be aware of this limitation when -evaluating whether EventSource is suitable for your specific use case. +> Please note that this package was originally developed to be used in conjunction with another package, and as such, it may not cover all specification details. Please be aware of this limitation when evaluating whether EventSource is suitable for your specific use case. + +## Features + +- [x] Simple Swift API for SSE +- [x] Supports data-only mode +- [x] Data race safety with Swift 6 +- [ ] Broadcast event stream to multiple consumers (WIP) ## Installation -The module name of the package is `EventSource`. Choose one of the instructions below to install and add -the following import statement to your source code. +The module name of the package is `EventSource`. Choose one of the instructions below to install and add the following import statement to your source code. ```swift import EventSource @@ -52,7 +53,7 @@ And then, include "EventSource" as a dependency for your target: ## Usage -Using EventSource is easy. Simply create a new task from an instance of EventSource with the URLRequest of the SSE endpoint you want to connect to, and await for events: +Using EventSource is easy. Simply create a new data task from an instance of EventSource with the URLRequest of the SSE endpoint you want to connect to, and await for events: ```swift import EventSource @@ -75,6 +76,47 @@ for await event in await dataTask.events() { Use `dataTask.cancel()` to explicitly close the connection. However, in that case `.closed` event won't be emitted. +### Data-only mode + +EventSource can be used in data-only mode, making it suitable for popular APIs like [OpenAI](https://platform.openai.com/docs/overview). Below is an example using OpenAI's [completions](https://platform.openai.com/docs/guides/text-generation) API: +```swift +var urlRequest = URLRequest(url: URL(string: "https://api.openai.com/v1/chat/completions")!) +urlRequest.allHTTPHeaderFields = [ + "Content-Type": "application/json", + "Authorization": "Bearer \(accessToken)" +] +urlRequest.httpMethod = "POST" +urlRequest.httpBody = """ +{ + "model": "gpt-4o-mini", + "messages": [ + {"role": "user", "content": "Why is the sky blue?"} + ], + "stream": true +} +""".data(using: .utf8)! + +let eventSource = EventSource(mode: .dataOnly) +let dataTask = await eventSource.dataTask(for: urlRequest) + +var response: String = "" + +for await event in await dataTask.events() { + switch event { + case .event(let event): + if let eventData = event.data, let data = eventData.data(using: .utf8) { + let chunk = try? JSONDecoder().decode(ChatCompletionChunk.self, from: data) + let string = chunk?.choices.first?.delta.content ?? "" + response += string + } + default: + break + } +} + +print(response) +``` + ## Compatibility * macOS 10.15+ @@ -94,4 +136,3 @@ Contributions to are always welcomed! For more details see [CONTRIBUTING.md](CON ## License EventSource is released under the MIT License. See [LICENSE](LICENSE) for more information. -