diff --git a/.swiftpm/configuration/Package.resolved b/.swiftpm/configuration/Package.resolved new file mode 100644 index 000000000..dee6efd6a --- /dev/null +++ b/.swiftpm/configuration/Package.resolved @@ -0,0 +1,59 @@ +{ + "pins" : [ + { + "identity" : "swift-concurrency-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-concurrency-extras", + "state" : { + "revision" : "bb5059bde9022d69ac516803f4f227d8ac967f71", + "version" : "1.1.0" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "bc1c29221f6dfeb0ebbfbc98eb95cd3d4967868e", + "version" : "3.4.0" + } + }, + { + "identity" : "swift-custom-dump", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-custom-dump", + "state" : { + "revision" : "aec6a73f5c1dc1f1be4f61888094b95cf995d973", + "version" : "1.3.2" + } + }, + { + "identity" : "swift-snapshot-testing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-snapshot-testing", + "state" : { + "revision" : "c097f955b4e724690f0fc8ffb7a6d4b881c9c4e3", + "version" : "1.17.2" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax", + "state" : { + "revision" : "303e5c5c36d6a558407d364878df131c3546fad8", + "version" : "510.0.2" + } + }, + { + "identity" : "xctest-dynamic-overlay", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", + "state" : { + "revision" : "357ca1e5dd31f613a1d43320870ebc219386a495", + "version" : "1.2.2" + } + } + ], + "version" : 2 +} diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/Supabase.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/Supabase.xcscheme index 709700c6e..73d8b0ca6 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/Supabase.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/Supabase.xcscheme @@ -35,6 +35,9 @@ + + URL var status: @Sendable () -> RealtimeClientV2.Status var options: @Sendable () -> RealtimeClientOptions var accessToken: @Sendable () -> String? + var apiKey: @Sendable () -> String? var makeRef: @Sendable () -> Int var connect: @Sendable () async -> Void var addChannel: @Sendable (_ channel: RealtimeChannelV2) -> Void var removeChannel: @Sendable (_ channel: RealtimeChannelV2) async -> Void var push: @Sendable (_ message: RealtimeMessageV2) async -> Void + var httpSend: @Sendable (_ request: HTTPRequest) async throws -> HTTPResponse } extension Socket { init(client: RealtimeClientV2) { self.init( + broadcastURL: { [weak client] in client?.broadcastURL ?? URL(string: "http://localhost")! }, status: { [weak client] in client?.status ?? .disconnected }, options: { [weak client] in client?.options ?? .init() }, accessToken: { [weak client] in client?.mutableState.accessToken }, + apiKey: { [weak client] in client?.apikey }, makeRef: { [weak client] in client?.makeRef() ?? 0 }, connect: { [weak client] in await client?.connect() }, addChannel: { [weak client] in client?.addChannel($0) }, removeChannel: { [weak client] in await client?.removeChannel($0) }, - push: { [weak client] in await client?.push($0) } + push: { [weak client] in await client?.push($0) }, + httpSend: { [weak client] in try await client?.http.send($0) ?? .init(data: Data(), response: HTTPURLResponse()) } ) } } @@ -202,24 +223,64 @@ public final class RealtimeChannelV2: Sendable { /// - event: Broadcast message event. /// - message: Message payload. public func broadcast(event: String, message: JSONObject) async { - assert( - status == .subscribed, - "You can only broadcast after subscribing to the channel. Did you forget to call `channel.subscribe()`?" - ) + if status != .subscribed { + struct Message: Encodable { + let topic: String + let event: String + let payload: JSONObject + let `private`: Bool + } - await push( - RealtimeMessageV2( - joinRef: mutableState.joinRef, - ref: socket.makeRef().description, - topic: topic, - event: ChannelEvent.broadcast, - payload: [ - "type": "broadcast", - "event": .string(event), - "payload": .object(message), - ] + var headers = HTTPHeaders(["content-type": "application/json"]) + if let apiKey = socket.apiKey() { + headers["apikey"] = apiKey + } + if let accessToken = socket.accessToken() { + headers["authorization"] = "Bearer \(accessToken)" + } + + let task = Task { [headers] in + _ = try? await socket.httpSend( + HTTPRequest( + url: socket.broadcastURL(), + method: .post, + headers: headers, + body: JSONEncoder().encode( + [ + "messages": [ + Message( + topic: topic, + event: event, + payload: message, + private: config.isPrivate + ), + ], + ] + ) + ) + ) + } + + if config.broadcast.acknowledgeBroadcasts { + try? await withTimeout(interval: socket.options().timeoutInterval) { + await task.value + } + } + } else { + await push( + RealtimeMessageV2( + joinRef: mutableState.joinRef, + ref: socket.makeRef().description, + topic: topic, + event: ChannelEvent.broadcast, + payload: [ + "type": "broadcast", + "event": .string(event), + "payload": .object(message), + ] + ) ) - ) + } } public func track(_ state: some Codable) async throws { diff --git a/Sources/Realtime/V2/RealtimeClientV2.swift b/Sources/Realtime/V2/RealtimeClientV2.swift index f202de0d1..acc15fc6c 100644 --- a/Sources/Realtime/V2/RealtimeClientV2.swift +++ b/Sources/Realtime/V2/RealtimeClientV2.swift @@ -79,6 +79,7 @@ public final class RealtimeClientV2: Sendable { let options: RealtimeClientOptions let ws: any WebSocketClient let mutableState = LockIsolated(MutableState()) + let http: any HTTPClientType let apikey: String? public var subscriptions: [String: RealtimeChannelV2] { @@ -128,6 +129,12 @@ public final class RealtimeClientV2: Sendable { } public convenience init(url: URL, options: RealtimeClientOptions) { + var interceptors: [any HTTPClientInterceptor] = [] + + if let logger = options.logger { + interceptors.append(LoggerInterceptor(logger: logger)) + } + self.init( url: url, options: options, @@ -137,14 +144,24 @@ public final class RealtimeClientV2: Sendable { apikey: options.apikey ), options: options + ), + http: HTTPClient( + fetch: options.fetch ?? { try await URLSession.shared.data(for: $0) }, + interceptors: interceptors ) ) } - init(url: URL, options: RealtimeClientOptions, ws: any WebSocketClient) { + init( + url: URL, + options: RealtimeClientOptions, + ws: any WebSocketClient, + http: any HTTPClientType + ) { self.url = url self.options = options self.ws = ws + self.http = http apikey = options.apikey mutableState.withValue { @@ -471,7 +488,7 @@ public final class RealtimeClientV2: Sendable { return url } - private var broadcastURL: URL { + var broadcastURL: URL { url.appendingPathComponent("api/broadcast") } } diff --git a/Sources/Realtime/V2/Types.swift b/Sources/Realtime/V2/Types.swift index eeaab64eb..b402cc599 100644 --- a/Sources/Realtime/V2/Types.swift +++ b/Sources/Realtime/V2/Types.swift @@ -8,6 +8,10 @@ import Foundation import Helpers +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + /// Options for initializing ``RealtimeClientV2``. public struct RealtimeClientOptions: Sendable { package var headers: HTTPHeaders @@ -16,6 +20,7 @@ public struct RealtimeClientOptions: Sendable { var timeoutInterval: TimeInterval var disconnectOnSessionLoss: Bool var connectOnSubscribe: Bool + var fetch: (@Sendable (_ request: URLRequest) async throws -> (Data, URLResponse))? package var logger: (any SupabaseLogger)? public static let defaultHeartbeatInterval: TimeInterval = 15 @@ -31,6 +36,7 @@ public struct RealtimeClientOptions: Sendable { timeoutInterval: TimeInterval = Self.defaultTimeoutInterval, disconnectOnSessionLoss: Bool = Self.defaultDisconnectOnSessionLoss, connectOnSubscribe: Bool = Self.defaultConnectOnSubscribe, + fetch: (@Sendable (_ request: URLRequest) async throws -> (Data, URLResponse))? = nil, logger: (any SupabaseLogger)? = nil ) { self.headers = HTTPHeaders(headers) @@ -39,6 +45,7 @@ public struct RealtimeClientOptions: Sendable { self.timeoutInterval = timeoutInterval self.disconnectOnSessionLoss = disconnectOnSessionLoss self.connectOnSubscribe = connectOnSubscribe + self.fetch = fetch self.logger = logger } diff --git a/Supabase.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Supabase.xcworkspace/xcshareddata/swiftpm/Package.resolved index 87bffe20a..a3bc22bb5 100644 --- a/Supabase.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Supabase.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,4 +1,5 @@ { + "originHash" : "7cc8037abb258fd1406effe711922c8d309c2e7a93147bfe68753fd05392a24a", "pins" : [ { "identity" : "appauth-ios", @@ -50,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-case-paths", "state" : { - "revision" : "031704ba0634b45e02fe875b8ddddc7f30a07f49", - "version" : "1.5.3" + "revision" : "71344dd930fde41e8f3adafe260adcbb2fc2a3dc", + "version" : "1.5.4" } }, { @@ -63,31 +64,13 @@ "version" : "1.1.2" } }, - { - "identity" : "swift-concurrency-extras", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-concurrency-extras", - "state" : { - "revision" : "bb5059bde9022d69ac516803f4f227d8ac967f71", - "version" : "1.1.0" - } - }, - { - "identity" : "swift-crypto", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-crypto.git", - "state" : { - "revision" : "46072478ca365fe48370993833cb22de9b41567f", - "version" : "3.5.2" - } - }, { "identity" : "swift-custom-dump", "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-custom-dump", "state" : { - "revision" : "d237304f42af07f22563aa4cc2d7e2cfb25da82e", - "version" : "1.3.1" + "revision" : "aec6a73f5c1dc1f1be4f61888094b95cf995d973", + "version" : "1.3.2" } }, { @@ -100,41 +83,32 @@ } }, { - "identity" : "swift-issue-reporting", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-issue-reporting", - "state" : { - "revision" : "926f43898706eaa127db79ac42138e1ad7e85a3f", - "version" : "1.2.0" - } - }, - { - "identity" : "swift-snapshot-testing", + "identity" : "swift-syntax", "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-snapshot-testing", + "location" : "https://github.com/swiftlang/swift-syntax", "state" : { - "revision" : "c097f955b4e724690f0fc8ffb7a6d4b881c9c4e3", - "version" : "1.17.2" + "revision" : "82a453c2dfa335c7e778695762438dfe72b328d2", + "version" : "600.0.0-prerelease-2024-07-24" } }, { - "identity" : "swift-syntax", + "identity" : "swiftui-navigation", "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-syntax", + "location" : "https://github.com/pointfreeco/swiftui-navigation.git", "state" : { - "revision" : "4c6cc0a3b9e8f14b3ae2307c5ccae4de6167ac2c", - "version" : "600.0.0-prerelease-2024-06-12" + "revision" : "fc91d591ebba1f90d65028ccb65c861e5979e898", + "version" : "1.5.4" } }, { - "identity" : "swiftui-navigation", + "identity" : "xctest-dynamic-overlay", "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swiftui-navigation.git", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "state" : { - "revision" : "97f854044356ac082e7e698f39264cc035544d77", - "version" : "1.5.2" + "revision" : "357ca1e5dd31f613a1d43320870ebc219386a495", + "version" : "1.2.2" } } ], - "version" : 2 + "version" : 3 } diff --git a/TestPlans/AllTests.xctestplan b/TestPlans/AllTests.xctestplan index adf11dd58..bb36d65c1 100644 --- a/TestPlans/AllTests.xctestplan +++ b/TestPlans/AllTests.xctestplan @@ -57,8 +57,8 @@ { "target" : { "containerPath" : "container:", - "identifier" : "_HelpersTests", - "name" : "_HelpersTests" + "identifier" : "HelpersTests", + "name" : "HelpersTests" } } ], diff --git a/Tests/HelpersTests/AnyJSONTests.swift b/Tests/HelpersTests/AnyJSONTests.swift index 1369eac8a..3253b34ce 100644 --- a/Tests/HelpersTests/AnyJSONTests.swift +++ b/Tests/HelpersTests/AnyJSONTests.swift @@ -82,7 +82,7 @@ final class AnyJSONTests: XCTestCase { // } func testInitFromCodable() { - expectNoDifference(try AnyJSON(jsonObject), jsonObject) + try expectNoDifference(AnyJSON(jsonObject), jsonObject) let codableValue = CodableValue( integer: 1, @@ -104,8 +104,8 @@ final class AnyJSONTests: XCTestCase { "any_json": jsonObject, ] - expectNoDifference(try AnyJSON(codableValue), json) - expectNoDifference(codableValue, try json.decode(as: CodableValue.self)) + try expectNoDifference(AnyJSON(codableValue), json) + try expectNoDifference(codableValue, json.decode(as: CodableValue.self)) } } diff --git a/Tests/IntegrationTests/RealtimeIntegrationTests.swift b/Tests/IntegrationTests/RealtimeIntegrationTests.swift index 3c9daf53a..07b498247 100644 --- a/Tests/IntegrationTests/RealtimeIntegrationTests.swift +++ b/Tests/IntegrationTests/RealtimeIntegrationTests.swift @@ -36,131 +36,147 @@ final class RealtimeIntegrationTests: XCTestCase { logger: Logger() ) + override func invokeTest() { + withMainSerialExecutor { + super.invokeTest() + } + } + func testBroadcast() async throws { - try await withMainSerialExecutor { - let expectation = expectation(description: "receivedBroadcastMessages") - expectation.expectedFulfillmentCount = 3 + let expectation = expectation(description: "receivedBroadcastMessages") + expectation.expectedFulfillmentCount = 3 - let channel = realtime.channel("integration") { - $0.broadcast.receiveOwnBroadcasts = true - } + let channel = realtime.channel("integration") { + $0.broadcast.receiveOwnBroadcasts = true + } - let receivedMessages = LockIsolated<[JSONObject]>([]) + let receivedMessages = LockIsolated<[JSONObject]>([]) - Task { - for await message in channel.broadcastStream(event: "test") { - receivedMessages.withValue { - $0.append(message) - } - expectation.fulfill() + Task { + for await message in channel.broadcastStream(event: "test") { + receivedMessages.withValue { + $0.append(message) } + expectation.fulfill() } + } - await Task.yield() + await Task.yield() - await channel.subscribe() + await channel.subscribe() - struct Message: Codable { - var value: Int - } + struct Message: Codable { + var value: Int + } - try await channel.broadcast(event: "test", message: Message(value: 1)) - try await channel.broadcast(event: "test", message: Message(value: 2)) - try await channel.broadcast(event: "test", message: ["value": 3, "another_value": 42]) + try await channel.broadcast(event: "test", message: Message(value: 1)) + try await channel.broadcast(event: "test", message: Message(value: 2)) + try await channel.broadcast(event: "test", message: ["value": 3, "another_value": 42]) - await fulfillment(of: [expectation], timeout: 0.5) + await fulfillment(of: [expectation], timeout: 0.5) - expectNoDifference( - receivedMessages.value, + expectNoDifference( + receivedMessages.value, + [ [ - [ - "event": "test", - "payload": [ - "value": 1, - ], - "type": "broadcast", + "event": "test", + "payload": [ + "value": 1, ], - [ - "event": "test", - "payload": [ - "value": 2, - ], - "type": "broadcast", + "type": "broadcast", + ], + [ + "event": "test", + "payload": [ + "value": 2, ], - [ - "event": "test", - "payload": [ - "value": 3, - "another_value": 42, - ], - "type": "broadcast", + "type": "broadcast", + ], + [ + "event": "test", + "payload": [ + "value": 3, + "another_value": 42, ], - ] - ) + "type": "broadcast", + ], + ] + ) + + await channel.unsubscribe() + } + + func testBroadcastWithUnsubscribedChannel() async throws { + let channel = realtime.channel("integration") { + $0.broadcast.acknowledgeBroadcasts = true + } - await channel.unsubscribe() + struct Message: Codable { + var value: Int } + + try await channel.broadcast(event: "test", message: Message(value: 1)) + try await channel.broadcast(event: "test", message: Message(value: 2)) + try await channel.broadcast(event: "test", message: ["value": 3, "another_value": 42]) } func testPresence() async throws { - try await withMainSerialExecutor { - let channel = realtime.channel("integration") { - $0.broadcast.receiveOwnBroadcasts = true - } + let channel = realtime.channel("integration") { + $0.broadcast.receiveOwnBroadcasts = true + } - let expectation = expectation(description: "presenceChange") - expectation.expectedFulfillmentCount = 4 + let expectation = expectation(description: "presenceChange") + expectation.expectedFulfillmentCount = 4 - let receivedPresenceChanges = LockIsolated<[any PresenceAction]>([]) + let receivedPresenceChanges = LockIsolated<[any PresenceAction]>([]) - Task { - for await presence in channel.presenceChange() { - receivedPresenceChanges.withValue { - $0.append(presence) - } - expectation.fulfill() + Task { + for await presence in channel.presenceChange() { + receivedPresenceChanges.withValue { + $0.append(presence) } + expectation.fulfill() } + } - await Task.yield() + await Task.yield() - await channel.subscribe() + await channel.subscribe() - struct UserState: Codable, Equatable { - let email: String - } + struct UserState: Codable, Equatable { + let email: String + } - try await channel.track(UserState(email: "test@supabase.com")) - try await channel.track(["email": "test2@supabase.com"]) + try await channel.track(UserState(email: "test@supabase.com")) + try await channel.track(["email": "test2@supabase.com"]) - await channel.untrack() + await channel.untrack() - await fulfillment(of: [expectation], timeout: 0.5) + await fulfillment(of: [expectation], timeout: 0.5) - let joins = try receivedPresenceChanges.value.map { try $0.decodeJoins(as: UserState.self) } - let leaves = try receivedPresenceChanges.value.map { try $0.decodeLeaves(as: UserState.self) } - expectNoDifference( - joins, - [ - [], // This is the first PRESENCE_STATE event. - [UserState(email: "test@supabase.com")], - [UserState(email: "test2@supabase.com")], - [], - ] - ) - - expectNoDifference( - leaves, - [ - [], // This is the first PRESENCE_STATE event. - [], - [UserState(email: "test@supabase.com")], - [UserState(email: "test2@supabase.com")], - ] - ) - - await channel.unsubscribe() - } + let joins = try receivedPresenceChanges.value.map { try $0.decodeJoins(as: UserState.self) } + let leaves = try receivedPresenceChanges.value.map { try $0.decodeLeaves(as: UserState.self) } + expectNoDifference( + joins, + [ + [], // This is the first PRESENCE_STATE event. + [UserState(email: "test@supabase.com")], + [UserState(email: "test2@supabase.com")], + [], + ] + ) + + expectNoDifference( + leaves, + [ + [], // This is the first PRESENCE_STATE event. + [], + [UserState(email: "test@supabase.com")], + [UserState(email: "test2@supabase.com")], + ] + ) + + await channel.unsubscribe() } // FIXME: Test getting stuck diff --git a/Tests/RealtimeTests/RealtimeTests.swift b/Tests/RealtimeTests/RealtimeTests.swift index f6e8fb7b7..be91d3843 100644 --- a/Tests/RealtimeTests/RealtimeTests.swift +++ b/Tests/RealtimeTests/RealtimeTests.swift @@ -5,6 +5,10 @@ import Helpers import TestHelpers import XCTest +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + final class RealtimeTests: XCTestCase { let url = URL(string: "https://localhost:54321/realtime/v1")! let apiKey = "anon.api.key" @@ -16,12 +20,14 @@ final class RealtimeTests: XCTestCase { } var ws: MockWebSocketClient! + var http: HTTPClientMock! var sut: RealtimeClientV2! override func setUp() { super.setUp() ws = MockWebSocketClient() + http = HTTPClientMock() sut = RealtimeClientV2( url: url, options: RealtimeClientOptions( @@ -31,7 +37,8 @@ final class RealtimeTests: XCTestCase { timeoutInterval: 2, logger: TestLogger() ), - ws: ws + ws: ws, + http: http ) } @@ -234,6 +241,54 @@ final class RealtimeTests: XCTestCase { ) } + func testBroadcastWithHTTP() async throws { + await http.when { + $0.url.path.hasSuffix("broadcast") + } return: { _ in + HTTPResponse( + data: "{}".data(using: .utf8)!, + response: HTTPURLResponse( + url: self.sut.broadcastURL, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + ) + } + + let channel = sut.channel("public:messages") { + $0.broadcast.acknowledgeBroadcasts = true + } + + try await channel.broadcast(event: "test", message: ["value": 42]) + + let request = await http.receivedRequests.last + expectNoDifference( + request?.headers, + [ + "content-type": "application/json", + "apikey": "anon.api.key", + "authorization": "Bearer anon.api.key", + ] + ) + + let body = try XCTUnwrap(request?.body) + let json = try JSONDecoder().decode(JSONObject.self, from: body) + expectNoDifference( + json, + [ + "messages": [ + [ + "topic": "realtime:public:messages", + "event": "test", + "payload": ["value": 42], + "private": false, + ], + ], + ] + ) + } + private func connectSocketAndWait() async { ws.mockConnect(.connected) await sut.connect() diff --git a/Tests/RealtimeTests/_PushTests.swift b/Tests/RealtimeTests/_PushTests.swift index 3c7aef5be..67efc7a14 100644 --- a/Tests/RealtimeTests/_PushTests.swift +++ b/Tests/RealtimeTests/_PushTests.swift @@ -29,7 +29,8 @@ final class _PushTests: XCTestCase { options: RealtimeClientOptions( headers: ["apiKey": "apikey"] ), - ws: ws + ws: ws, + http: HTTPClientMock() ) }