From bfc2c562e5df1086c060aa63603b4342c31ebd43 Mon Sep 17 00:00:00 2001 From: Bryan Dubno Date: Fri, 10 May 2024 09:46:15 -0700 Subject: [PATCH 1/9] Update RealtimeClientV2.swift Provide a means to configure RealtimeV2 options --- Sources/Realtime/V2/RealtimeClientV2.swift | 41 ++++++++++++++++------ 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/Sources/Realtime/V2/RealtimeClientV2.swift b/Sources/Realtime/V2/RealtimeClientV2.swift index e88cda5f..47c0836e 100644 --- a/Sources/Realtime/V2/RealtimeClientV2.swift +++ b/Sources/Realtime/V2/RealtimeClientV2.swift @@ -16,6 +16,29 @@ import Foundation public typealias JSONObject = _Helpers.JSONObject public actor RealtimeClientV2 { + public struct ConfigurationOptions: Sendable { + var heartbeatInterval: TimeInterval + var reconnectDelay: TimeInterval + var timeoutInterval: TimeInterval + var disconnectOnSessionLoss: Bool + var connectOnSubscribe: Bool + + public init( + heartbeatInterval: TimeInterval = 15, + reconnectDelay: TimeInterval = 7, + timeoutInterval: TimeInterval = 10, + disconnectOnSessionLoss: Bool = true, + connectOnSubscribe: Bool = true + ) { + self.heartbeatInterval = heartbeatInterval + self.reconnectDelay = reconnectDelay + self.timeoutInterval = timeoutInterval + self.disconnectOnSessionLoss = disconnectOnSessionLoss + self.connectOnSubscribe = connectOnSubscribe + self.logger = logger + } + } + public struct Configuration: Sendable { var url: URL var apiKey: String @@ -26,26 +49,22 @@ public actor RealtimeClientV2 { var disconnectOnSessionLoss: Bool var connectOnSubscribe: Bool var logger: (any SupabaseLogger)? - + public init( url: URL, apiKey: String, headers: [String: String] = [:], - heartbeatInterval: TimeInterval = 15, - reconnectDelay: TimeInterval = 7, - timeoutInterval: TimeInterval = 10, - disconnectOnSessionLoss: Bool = true, - connectOnSubscribe: Bool = true, + options: ConfigurationOptions = ConfigurationOptions(), logger: (any SupabaseLogger)? = nil ) { self.url = url self.apiKey = apiKey self.headers = headers - self.heartbeatInterval = heartbeatInterval - self.reconnectDelay = reconnectDelay - self.timeoutInterval = timeoutInterval - self.disconnectOnSessionLoss = disconnectOnSessionLoss - self.connectOnSubscribe = connectOnSubscribe + self.heartbeatInterval = options.heartbeatInterval + self.reconnectDelay = options.reconnectDelay + self.timeoutInterval = options.timeoutInterval + self.disconnectOnSessionLoss = options.disconnectOnSessionLoss + self.connectOnSubscribe = options.connectOnSubscribe self.logger = logger } } From 128d2d0e5e71111b8966769877ea314cb19f5aa4 Mon Sep 17 00:00:00 2001 From: Bryan Dubno Date: Fri, 10 May 2024 09:50:33 -0700 Subject: [PATCH 2/9] Update SupabaseClient.swift --- Sources/Supabase/SupabaseClient.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/Supabase/SupabaseClient.swift b/Sources/Supabase/SupabaseClient.swift index 0bf2f4e0..0851da27 100644 --- a/Sources/Supabase/SupabaseClient.swift +++ b/Sources/Supabase/SupabaseClient.swift @@ -108,7 +108,8 @@ public final class SupabaseClient: @unchecked Sendable { public init( supabaseURL: URL, supabaseKey: String, - options: SupabaseClientOptions + options: SupabaseClientOptions, + realtimeOptions: RealtimeClientV2.ConfigurationOptions = RealtimeClientV2.ConfigurationOptions() ) { self.supabaseURL = supabaseURL self.supabaseKey = supabaseKey @@ -150,6 +151,7 @@ public final class SupabaseClient: @unchecked Sendable { url: supabaseURL.appendingPathComponent("/realtime/v1"), apiKey: supabaseKey, headers: defaultHeaders, + options: realtimeOptions, logger: options.global.logger ) ) From a5e61b979103f586d6066f4c5cbbf87bdce42186 Mon Sep 17 00:00:00 2001 From: Bryan Dubno Date: Fri, 10 May 2024 09:55:12 -0700 Subject: [PATCH 3/9] Update RealtimeClientV2.swift --- Sources/Realtime/V2/RealtimeClientV2.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Sources/Realtime/V2/RealtimeClientV2.swift b/Sources/Realtime/V2/RealtimeClientV2.swift index 47c0836e..a8699420 100644 --- a/Sources/Realtime/V2/RealtimeClientV2.swift +++ b/Sources/Realtime/V2/RealtimeClientV2.swift @@ -35,7 +35,6 @@ public actor RealtimeClientV2 { self.timeoutInterval = timeoutInterval self.disconnectOnSessionLoss = disconnectOnSessionLoss self.connectOnSubscribe = connectOnSubscribe - self.logger = logger } } From 73e4e3a91f6c17da209553369ea2cd2c47c10b7c Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Mon, 13 May 2024 15:59:11 -0300 Subject: [PATCH 4/9] feat(realtime): add RealtimeClientOptions and expose it to SupbaseClient --- Package.swift | 9 +- Sources/Auth/AuthClient.swift | 10 +- Sources/Realtime/V2/RealtimeChannelV2.swift | 2 +- Sources/Realtime/V2/RealtimeClientV2.swift | 149 +++++++----------- Sources/Realtime/V2/Types.swift | 55 +++++++ Sources/Realtime/V2/WebSocketClient.swift | 8 +- Sources/Supabase/SupabaseClient.swift | 44 +++--- Sources/Supabase/Types.swift | 10 +- Sources/_Helpers/HTTP/HTTPHeader.swift | 6 + .../RealtimeIntegrationTests.swift | 6 +- Tests/RealtimeTests/RealtimeTests.swift | 6 +- Tests/RealtimeTests/_PushTests.swift | 6 +- Tests/SupabaseTests/SupabaseClientTests.swift | 18 ++- 13 files changed, 191 insertions(+), 138 deletions(-) create mode 100644 Sources/Realtime/V2/Types.swift diff --git a/Package.swift b/Package.swift index c9e8ca6d..58915098 100644 --- a/Package.swift +++ b/Package.swift @@ -158,11 +158,18 @@ let package = Package( "Functions", ] ), - .testTarget(name: "SupabaseTests", dependencies: ["Supabase"]), + .testTarget( + name: "SupabaseTests", + dependencies: [ + "Supabase", + .product(name: "CustomDump", package: "swift-custom-dump"), + ] + ), .target( name: "TestHelpers", dependencies: [ .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), + .product(name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay"), "Auth", ] ), diff --git a/Sources/Auth/AuthClient.swift b/Sources/Auth/AuthClient.swift index 7f46fcaa..f95e27c5 100644 --- a/Sources/Auth/AuthClient.swift +++ b/Sources/Auth/AuthClient.swift @@ -699,13 +699,13 @@ public final class AuthClient: Sendable { /// - Parameter scope: Specifies which sessions should be logged out. public func signOut(scope: SignOutScope = .global) async throws { guard let accessToken = currentSession?.accessToken else { - configuration.logger?.warning("signOut called without a session") - return + configuration.logger?.warning("signOut called without a session") + return } if scope != .others { - await sessionManager.remove() - eventEmitter.emit(.signedOut, session: nil) + await sessionManager.remove() + eventEmitter.emit(.signedOut, session: nil) } do { @@ -713,7 +713,7 @@ public final class AuthClient: Sendable { .init( url: configuration.url.appendingPathComponent("logout"), method: .post, - query: [URLQueryItem(name: "scope", value: scope.rawValue)], + query: [URLQueryItem(name: "scope", value: scope.rawValue)], headers: [.init(name: "Authorization", value: "Bearer \(accessToken)")] ) ) diff --git a/Sources/Realtime/V2/RealtimeChannelV2.swift b/Sources/Realtime/V2/RealtimeChannelV2.swift index 4ab3d340..e0a8a8a5 100644 --- a/Sources/Realtime/V2/RealtimeChannelV2.swift +++ b/Sources/Realtime/V2/RealtimeChannelV2.swift @@ -70,7 +70,7 @@ public actor RealtimeChannelV2 { /// Subscribes to the channel public func subscribe() async { if await socket?.status != .connected { - if socket?.config.connectOnSubscribe != true { + if socket?.options.connectOnSubscribe != true { fatalError( "You can't subscribe to a channel while the realtime client is not connected. Did you forget to call `realtime.connect()`?" ) diff --git a/Sources/Realtime/V2/RealtimeClientV2.swift b/Sources/Realtime/V2/RealtimeClientV2.swift index a8699420..0960afce 100644 --- a/Sources/Realtime/V2/RealtimeClientV2.swift +++ b/Sources/Realtime/V2/RealtimeClientV2.swift @@ -16,57 +16,8 @@ import Foundation public typealias JSONObject = _Helpers.JSONObject public actor RealtimeClientV2 { - public struct ConfigurationOptions: Sendable { - var heartbeatInterval: TimeInterval - var reconnectDelay: TimeInterval - var timeoutInterval: TimeInterval - var disconnectOnSessionLoss: Bool - var connectOnSubscribe: Bool - - public init( - heartbeatInterval: TimeInterval = 15, - reconnectDelay: TimeInterval = 7, - timeoutInterval: TimeInterval = 10, - disconnectOnSessionLoss: Bool = true, - connectOnSubscribe: Bool = true - ) { - self.heartbeatInterval = heartbeatInterval - self.reconnectDelay = reconnectDelay - self.timeoutInterval = timeoutInterval - self.disconnectOnSessionLoss = disconnectOnSessionLoss - self.connectOnSubscribe = connectOnSubscribe - } - } - - public struct Configuration: Sendable { - var url: URL - var apiKey: String - var headers: [String: String] - var heartbeatInterval: TimeInterval - var reconnectDelay: TimeInterval - var timeoutInterval: TimeInterval - var disconnectOnSessionLoss: Bool - var connectOnSubscribe: Bool - var logger: (any SupabaseLogger)? - - public init( - url: URL, - apiKey: String, - headers: [String: String] = [:], - options: ConfigurationOptions = ConfigurationOptions(), - logger: (any SupabaseLogger)? = nil - ) { - self.url = url - self.apiKey = apiKey - self.headers = headers - self.heartbeatInterval = options.heartbeatInterval - self.reconnectDelay = options.reconnectDelay - self.timeoutInterval = options.timeoutInterval - self.disconnectOnSessionLoss = options.disconnectOnSessionLoss - self.connectOnSubscribe = options.connectOnSubscribe - self.logger = logger - } - } + @available(*, deprecated, renamed: "RealtimeClientOptions") + public typealias Configuration = RealtimeClientOptions public enum Status: Sendable, CustomStringConvertible { case disconnected @@ -82,10 +33,12 @@ public actor RealtimeClientV2 { } } - let config: Configuration + let url: URL + let options: RealtimeClientOptions let ws: any WebSocketClient var accessToken: String? + let apikey: String? var ref = 0 var pendingHeartbeatRef: Int? @@ -97,34 +50,50 @@ public actor RealtimeClientV2 { private let statusEventEmitter = EventEmitter(initialEvent: .disconnected) + /// AsyncStream that emits when connection status change. + /// + /// You can also use ``onStatusChange(_:)`` for a closure based method. public var statusChange: AsyncStream { statusEventEmitter.stream() } + /// The current connection status. public private(set) var status: Status { get { statusEventEmitter.lastEvent.value } set { statusEventEmitter.emit(newValue) } } + /// Listen for connection status changes. + /// - Parameter listener: Closure that will be called when connection status changes. + /// - Returns: An observation handle that can be used to stop listening. + /// + /// - Note: Use ``statusChange`` if you prefer to use Async/Await. public func onStatusChange( _ listener: @escaping @Sendable (Status) -> Void ) -> ObservationToken { statusEventEmitter.attach(listener) } - public init(config: Configuration) { - self.init(config: config, ws: WebSocket(config: config)) + public init(url: URL, config: RealtimeClientOptions) { + self.init( + url: url, + options: config, + ws: WebSocket( + realtimeURL: Self.realtimeWebSocketURL( + baseURL: Self.realtimeBaseURL(url: url), + apikey: config.apikey + ), + options: config + ) + ) } - init(config: Configuration, ws: any WebSocketClient) { - self.config = config + init(url: URL, options: RealtimeClientOptions, ws: any WebSocketClient) { + self.url = url + self.options = options self.ws = ws - - if let customJWT = config.headers["Authorization"]?.split(separator: " ").last { - accessToken = String(customJWT) - } else { - accessToken = config.apiKey - } + accessToken = options.accessToken ?? options.apikey + apikey = options.apikey } deinit { @@ -144,16 +113,16 @@ public actor RealtimeClientV2 { if status == .disconnected { connectionTask = Task { if reconnect { - try? await Task.sleep(nanoseconds: NSEC_PER_SEC * UInt64(config.reconnectDelay)) + try? await Task.sleep(nanoseconds: NSEC_PER_SEC * UInt64(options.reconnectDelay)) if Task.isCancelled { - config.logger?.debug("Reconnect cancelled, returning") + options.logger?.debug("Reconnect cancelled, returning") return } } if status == .connected { - config.logger?.debug("WebsSocket already connected") + options.logger?.debug("WebsSocket already connected") return } @@ -183,7 +152,7 @@ public actor RealtimeClientV2 { private func onConnected(reconnect: Bool) async { status = .connected - config.logger?.debug("Connected to realtime WebSocket") + options.logger?.debug("Connected to realtime WebSocket") listenForMessages() startHeartbeating() if reconnect { @@ -192,17 +161,17 @@ public actor RealtimeClientV2 { } private func onDisconnected() async { - config.logger? + options.logger? .debug( - "WebSocket disconnected. Trying again in \(config.reconnectDelay)" + "WebSocket disconnected. Trying again in \(options.reconnectDelay)" ) await reconnect() } private func onError(_ error: (any Error)?) async { - config.logger? + options.logger? .debug( - "WebSocket error \(error?.localizedDescription ?? ""). Trying again in \(config.reconnectDelay)" + "WebSocket error \(error?.localizedDescription ?? ""). Trying again in \(options.reconnectDelay)" ) await reconnect() } @@ -226,7 +195,7 @@ public actor RealtimeClientV2 { topic: "realtime:\(topic)", config: config, socket: self, - logger: self.config.logger + logger: self.options.logger ) } @@ -242,7 +211,7 @@ public actor RealtimeClientV2 { subscriptions[channel.topic] = nil if subscriptions.isEmpty { - config.logger?.debug("No more subscribed channel in socket") + options.logger?.debug("No more subscribed channel in socket") disconnect() } } @@ -272,8 +241,8 @@ public actor RealtimeClientV2 { await onMessage(message) } } catch { - config.logger?.debug( - "Error while listening for messages. Trying again in \(config.reconnectDelay) \(error)" + options.logger?.debug( + "Error while listening for messages. Trying again in \(options.reconnectDelay) \(error)" ) await reconnect() } @@ -281,9 +250,9 @@ public actor RealtimeClientV2 { } private func startHeartbeating() { - heartbeatTask = Task { [weak self, config] in + heartbeatTask = Task { [weak self, options] in while !Task.isCancelled { - try? await Task.sleep(nanoseconds: NSEC_PER_SEC * UInt64(config.heartbeatInterval)) + try? await Task.sleep(nanoseconds: NSEC_PER_SEC * UInt64(options.heartbeatInterval)) if Task.isCancelled { break } @@ -295,7 +264,7 @@ public actor RealtimeClientV2 { private func sendHeartbeat() async { if pendingHeartbeatRef != nil { pendingHeartbeatRef = nil - config.logger?.debug("Heartbeat timeout") + options.logger?.debug("Heartbeat timeout") await reconnect() return @@ -315,7 +284,7 @@ public actor RealtimeClientV2 { } public func disconnect() { - config.logger?.debug("Closing WebSocket connection") + options.logger?.debug("Closing WebSocket connection") ref = 0 messageTask?.cancel() heartbeatTask?.cancel() @@ -341,9 +310,9 @@ public actor RealtimeClientV2 { if let ref = message.ref, Int(ref) == pendingHeartbeatRef { pendingHeartbeatRef = nil - config.logger?.debug("heartbeat received") + options.logger?.debug("heartbeat received") } else { - config.logger? + options.logger? .debug("Received event \(message.event) for channel \(channel?.topic ?? "null")") await channel?.onMessage(message) } @@ -353,14 +322,14 @@ public actor RealtimeClientV2 { /// - Parameter message: The message to push through the socket. public func push(_ message: RealtimeMessageV2) async { guard status == .connected else { - config.logger?.warning("Trying to push a message while socket is not connected. This is not supported yet.") + options.logger?.warning("Trying to push a message while socket is not connected. This is not supported yet.") return } do { try await ws.send(message) } catch { - config.logger?.debug(""" + options.logger?.debug(""" Failed to send message: \(message) @@ -374,10 +343,8 @@ public actor RealtimeClientV2 { ref += 1 return ref } -} -extension RealtimeClientV2.Configuration { - var realtimeBaseURL: URL { + static func realtimeBaseURL(url: URL) -> URL { guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return url } @@ -395,21 +362,23 @@ extension RealtimeClientV2.Configuration { return url } - var realtimeWebSocketURL: URL { - guard var components = URLComponents(url: realtimeBaseURL, resolvingAgainstBaseURL: false) + static func realtimeWebSocketURL(baseURL: URL, apikey: String?) -> URL { + guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else { - return realtimeBaseURL + return baseURL } components.queryItems = components.queryItems ?? [] - components.queryItems!.append(URLQueryItem(name: "apikey", value: apiKey)) + if let apikey { + components.queryItems!.append(URLQueryItem(name: "apikey", value: apikey)) + } components.queryItems!.append(URLQueryItem(name: "vsn", value: "1.0.0")) components.path.append("/websocket") components.path = components.path.replacingOccurrences(of: "//", with: "/") guard let url = components.url else { - return realtimeBaseURL + return baseURL } return url diff --git a/Sources/Realtime/V2/Types.swift b/Sources/Realtime/V2/Types.swift new file mode 100644 index 00000000..9089b7b2 --- /dev/null +++ b/Sources/Realtime/V2/Types.swift @@ -0,0 +1,55 @@ +// +// Types.swift +// +// +// Created by Guilherme Souza on 13/05/24. +// + +import _Helpers +import Foundation + +/// Options for initializing ``RealtimeClientV2``. +public struct RealtimeClientOptions: Sendable { + package var headers: HTTPHeaders + var heartbeatInterval: TimeInterval + var reconnectDelay: TimeInterval + var timeoutInterval: TimeInterval + var disconnectOnSessionLoss: Bool + var connectOnSubscribe: Bool + package var logger: (any SupabaseLogger)? + + public static let defaultHeartbeatInterval: TimeInterval = 15 + public static let defaultReconnectDelay: TimeInterval = 7 + public static let defaultTimeoutInterval: TimeInterval = 10 + public static let defaultDisconnectOnSessionLoss = true + public static let defaultConnectOnSubscribe: Bool = true + + public init( + headers: [String: String] = [:], + heartbeatInterval: TimeInterval = Self.defaultHeartbeatInterval, + reconnectDelay: TimeInterval = Self.defaultReconnectDelay, + timeoutInterval: TimeInterval = Self.defaultTimeoutInterval, + disconnectOnSessionLoss: Bool = Self.defaultDisconnectOnSessionLoss, + connectOnSubscribe: Bool = Self.defaultConnectOnSubscribe, + logger: (any SupabaseLogger)? = nil + ) { + self.headers = HTTPHeaders(headers) + self.heartbeatInterval = heartbeatInterval + self.reconnectDelay = reconnectDelay + self.timeoutInterval = timeoutInterval + self.disconnectOnSessionLoss = disconnectOnSessionLoss + self.connectOnSubscribe = connectOnSubscribe + self.logger = logger + } + + var apikey: String? { + headers["apikey"] + } + + var accessToken: String? { + guard let accessToken = headers["Authorization"]?.split(separator: " ").last else { + return nil + } + return String(accessToken) + } +} diff --git a/Sources/Realtime/V2/WebSocketClient.swift b/Sources/Realtime/V2/WebSocketClient.swift index e6907986..9a2b1c36 100644 --- a/Sources/Realtime/V2/WebSocketClient.swift +++ b/Sources/Realtime/V2/WebSocketClient.swift @@ -44,13 +44,13 @@ final class WebSocket: NSObject, URLSessionWebSocketDelegate, WebSocketClient, @ private let mutableState = LockIsolated(MutableState()) - init(config: RealtimeClientV2.Configuration) { - realtimeURL = config.realtimeWebSocketURL + init(realtimeURL: URL, options: RealtimeClientOptions) { + self.realtimeURL = realtimeURL let sessionConfiguration = URLSessionConfiguration.default - sessionConfiguration.httpAdditionalHeaders = config.headers + sessionConfiguration.httpAdditionalHeaders = options.headers.dictionary configuration = sessionConfiguration - logger = config.logger + logger = options.logger } func connect() -> AsyncStream { diff --git a/Sources/Supabase/SupabaseClient.swift b/Sources/Supabase/SupabaseClient.swift index 0851da27..20c14105 100644 --- a/Sources/Supabase/SupabaseClient.swift +++ b/Sources/Supabase/SupabaseClient.swift @@ -43,7 +43,7 @@ public final class SupabaseClient: @unchecked Sendable { private lazy var rest = PostgrestClient( url: databaseURL, schema: options.db.schema, - headers: defaultHeaders, + headers: defaultHeaders.dictionary, logger: options.global.logger, fetch: fetchWithAuth, encoder: options.db.encoder, @@ -54,7 +54,7 @@ public final class SupabaseClient: @unchecked Sendable { public private(set) lazy var storage = SupabaseStorageClient( configuration: StorageClientConfiguration( url: storageURL, - headers: defaultHeaders, + headers: defaultHeaders.dictionary, session: StorageHTTPSession(fetch: fetchWithAuth, upload: uploadWithAuth), logger: options.global.logger ) @@ -69,13 +69,13 @@ public final class SupabaseClient: @unchecked Sendable { /// Supabase Functions allows you to deploy and invoke edge functions. public private(set) lazy var functions = FunctionsClient( url: functionsURL, - headers: defaultHeaders, + headers: defaultHeaders.dictionary, region: options.functions.region, logger: options.global.logger, fetch: fetchWithAuth ) - let defaultHeaders: [String: String] + let defaultHeaders: HTTPHeaders private let listenForAuthEventsTask = LockIsolated(Task?.none) private var session: URLSession { @@ -100,16 +100,13 @@ public final class SupabaseClient: @unchecked Sendable { /// Create a new client. /// - Parameters: - /// - supabaseURL: The unique Supabase URL which is supplied when you create a new project in - /// your project dashboard. - /// - supabaseKey: The unique Supabase Key which is supplied when you create a new project in - /// your project dashboard. + /// - supabaseURL: The unique Supabase URL which is supplied when you create a new project in your project dashboard. + /// - supabaseKey: The unique Supabase Key which is supplied when you create a new project in your project dashboard. /// - options: Custom options to configure client's behavior. public init( supabaseURL: URL, supabaseKey: String, - options: SupabaseClientOptions, - realtimeOptions: RealtimeClientV2.ConfigurationOptions = RealtimeClientV2.ConfigurationOptions() + options: SupabaseClientOptions ) { self.supabaseURL = supabaseURL self.supabaseKey = supabaseKey @@ -119,15 +116,16 @@ public final class SupabaseClient: @unchecked Sendable { databaseURL = supabaseURL.appendingPathComponent("/rest/v1") functionsURL = supabaseURL.appendingPathComponent("/functions/v1") - defaultHeaders = [ + defaultHeaders = HTTPHeaders([ "X-Client-Info": "supabase-swift/\(version)", "Authorization": "Bearer \(supabaseKey)", "Apikey": supabaseKey, - ].merging(options.global.headers) { _, new in new } + ]) + .merged(with: HTTPHeaders(options.global.headers)) auth = AuthClient( url: supabaseURL.appendingPathComponent("/auth/v1"), - headers: defaultHeaders, + headers: defaultHeaders.dictionary, flowType: options.auth.flowType, redirectToURL: options.auth.redirectToURL, localStorage: options.auth.storage, @@ -142,18 +140,20 @@ public final class SupabaseClient: @unchecked Sendable { realtime = RealtimeClient( supabaseURL.appendingPathComponent("/realtime/v1").absoluteString, - headers: defaultHeaders, - params: defaultHeaders + headers: defaultHeaders.dictionary, + params: defaultHeaders.dictionary ) + var realtimeOptions = options.realtime + realtimeOptions.headers = realtimeOptions.headers.merged(with: defaultHeaders) + + if realtimeOptions.logger == nil { + realtimeOptions.logger = options.global.logger + } + realtimeV2 = RealtimeClientV2( - config: RealtimeClientV2.Configuration( - url: supabaseURL.appendingPathComponent("/realtime/v1"), - apiKey: supabaseKey, - headers: defaultHeaders, - options: realtimeOptions, - logger: options.global.logger - ) + url: supabaseURL.appendingPathComponent("/realtime/v1"), + config: realtimeOptions ) listenForAuthEvents() diff --git a/Sources/Supabase/Types.swift b/Sources/Supabase/Types.swift index ff937792..b0439fef 100644 --- a/Sources/Supabase/Types.swift +++ b/Sources/Supabase/Types.swift @@ -2,6 +2,7 @@ import _Helpers import Auth import Foundation import PostgREST +import Realtime #if canImport(FoundationNetworking) import FoundationNetworking @@ -12,6 +13,7 @@ public struct SupabaseClientOptions: Sendable { public let auth: AuthOptions public let global: GlobalOptions public let functions: FunctionsOptions + public let realtime: RealtimeClientV2.Configuration public struct DatabaseOptions: Sendable { /// The Postgres schema which your tables belong to. Must be on the list of exposed schemas in @@ -106,12 +108,14 @@ public struct SupabaseClientOptions: Sendable { db: DatabaseOptions = .init(), auth: AuthOptions, global: GlobalOptions = .init(), - functions: FunctionsOptions = .init() + functions: FunctionsOptions = .init(), + realtime: RealtimeClientV2.Configuration = .init() ) { self.db = db self.auth = auth self.global = global self.functions = functions + self.realtime = realtime } } @@ -120,12 +124,14 @@ extension SupabaseClientOptions { public init( db: DatabaseOptions = .init(), global: GlobalOptions = .init(), - functions: FunctionsOptions = .init() + functions: FunctionsOptions = .init(), + realtime: RealtimeClientV2.Configuration = .init() ) { self.db = db auth = .init() self.global = global self.functions = functions + self.realtime = realtime } #endif } diff --git a/Sources/_Helpers/HTTP/HTTPHeader.swift b/Sources/_Helpers/HTTP/HTTPHeader.swift index 7ec27627..b3ec53ce 100644 --- a/Sources/_Helpers/HTTP/HTTPHeader.swift +++ b/Sources/_Helpers/HTTP/HTTPHeader.swift @@ -149,3 +149,9 @@ extension HTTPHeader: CustomStringConvertible { "\(name): \(value)" } } + +extension HTTPHeaders: Equatable { + package static func == (lhs: Self, rhs: Self) -> Bool { + lhs.dictionary == rhs.dictionary + } +} diff --git a/Tests/IntegrationTests/RealtimeIntegrationTests.swift b/Tests/IntegrationTests/RealtimeIntegrationTests.swift index 872a8707..5c365863 100644 --- a/Tests/IntegrationTests/RealtimeIntegrationTests.swift +++ b/Tests/IntegrationTests/RealtimeIntegrationTests.swift @@ -21,9 +21,9 @@ struct Logger: SupabaseLogger { final class RealtimeIntegrationTests: XCTestCase { let realtime = RealtimeClientV2( + url: URL(string: "\(DotEnv.SUPABASE_URL)/realtime/v1")!, config: RealtimeClientV2.Configuration( - url: URL(string: "\(DotEnv.SUPABASE_URL)/realtime/v1")!, - apiKey: DotEnv.SUPABASE_ANON_KEY, + headers: ["apikey": DotEnv.SUPABASE_ANON_KEY], logger: Logger() ) ) @@ -47,7 +47,7 @@ final class RealtimeIntegrationTests: XCTestCase { let receivedMessages = LockIsolated<[JSONObject]>([]) Task { - for await message in await channel.broadcast(event: "test") { + for await message in await channel.broadcastStream(event: "test") { receivedMessages.withValue { $0.append(message) } diff --git a/Tests/RealtimeTests/RealtimeTests.swift b/Tests/RealtimeTests/RealtimeTests.swift index 5c628527..4bb39313 100644 --- a/Tests/RealtimeTests/RealtimeTests.swift +++ b/Tests/RealtimeTests/RealtimeTests.swift @@ -23,9 +23,9 @@ final class RealtimeTests: XCTestCase { ws = MockWebSocketClient() sut = RealtimeClientV2( - config: RealtimeClientV2.Configuration( - url: url, - apiKey: apiKey, + url: url, + options: RealtimeClientOptions( + headers: ["apikey": apiKey], heartbeatInterval: 1, reconnectDelay: 1 ), diff --git a/Tests/RealtimeTests/_PushTests.swift b/Tests/RealtimeTests/_PushTests.swift index 5e493787..26b988be 100644 --- a/Tests/RealtimeTests/_PushTests.swift +++ b/Tests/RealtimeTests/_PushTests.swift @@ -25,9 +25,9 @@ final class _PushTests: XCTestCase { ws = MockWebSocketClient() socket = RealtimeClientV2( - config: RealtimeClientV2.Configuration( - url: URL(string: "https://localhost:54321/v1/realtime")!, - apiKey: "apikey" + url: URL(string: "https://localhost:54321/v1/realtime")!, + options: RealtimeClientOptions( + headers: ["apiKey": "apikey"] ), ws: ws ) diff --git a/Tests/SupabaseTests/SupabaseClientTests.swift b/Tests/SupabaseTests/SupabaseClientTests.swift index ef3686cf..b98755c3 100644 --- a/Tests/SupabaseTests/SupabaseClientTests.swift +++ b/Tests/SupabaseTests/SupabaseClientTests.swift @@ -1,8 +1,9 @@ import Auth -import XCTest - +import CustomDump @testable import Functions +@testable import Realtime @testable import Supabase +import XCTest final class AuthLocalStorageMock: AuthLocalStorage { func store(key _: String, value _: Data) throws {} @@ -32,6 +33,9 @@ final class SupabaseClientTests: XCTestCase { ), functions: SupabaseClientOptions.FunctionsOptions( region: .apNortheast1 + ), + realtime: RealtimeClientOptions( + headers: ["custom_realtime_header_key": "custom_realtime_header_value"] ) ) ) @@ -55,8 +59,14 @@ final class SupabaseClientTests: XCTestCase { ] ) - let region = await client.functions.region - XCTAssertEqual(region, "ap-northeast-1") + XCTAssertEqual(client.functions.region, "ap-northeast-1") + + let realtimeURL = await client.realtimeV2.url + XCTAssertEqual(realtimeURL.absoluteString, "https://project-ref.supabase.co/realtime/v1") + + let realtimeOptions = await client.realtimeV2.options + let expectedRealtimeHeader = client.defaultHeaders.merged(with: ["custom_realtime_header_key": "custom_realtime_header_value"]) + XCTAssertNoDifference(realtimeOptions.headers, expectedRealtimeHeader) } #if !os(Linux) From bae0dc37368d9b0749843d62ab5d6c49585022b5 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Mon, 13 May 2024 16:05:02 -0300 Subject: [PATCH 5/9] Add deprecated init to avoid breaking changes --- Sources/Realtime/V2/RealtimeClientV2.swift | 58 ++++++++++++++++++++-- Sources/Supabase/SupabaseClient.swift | 2 +- Sources/Supabase/Types.swift | 6 +-- 3 files changed, 57 insertions(+), 9 deletions(-) diff --git a/Sources/Realtime/V2/RealtimeClientV2.swift b/Sources/Realtime/V2/RealtimeClientV2.swift index 0960afce..ceddbef0 100644 --- a/Sources/Realtime/V2/RealtimeClientV2.swift +++ b/Sources/Realtime/V2/RealtimeClientV2.swift @@ -17,7 +17,39 @@ public typealias JSONObject = _Helpers.JSONObject public actor RealtimeClientV2 { @available(*, deprecated, renamed: "RealtimeClientOptions") - public typealias Configuration = RealtimeClientOptions + public struct Configuration: Sendable { + var url: URL + var apiKey: String + var headers: [String: String] + var heartbeatInterval: TimeInterval + var reconnectDelay: TimeInterval + var timeoutInterval: TimeInterval + var disconnectOnSessionLoss: Bool + var connectOnSubscribe: Bool + var logger: (any SupabaseLogger)? + + public init( + url: URL, + apiKey: String, + headers: [String: String] = [:], + heartbeatInterval: TimeInterval = 15, + reconnectDelay: TimeInterval = 7, + timeoutInterval: TimeInterval = 10, + disconnectOnSessionLoss: Bool = true, + connectOnSubscribe: Bool = true, + logger: (any SupabaseLogger)? = nil + ) { + self.url = url + self.apiKey = apiKey + self.headers = headers + self.heartbeatInterval = heartbeatInterval + self.reconnectDelay = reconnectDelay + self.timeoutInterval = timeoutInterval + self.disconnectOnSessionLoss = disconnectOnSessionLoss + self.connectOnSubscribe = connectOnSubscribe + self.logger = logger + } + } public enum Status: Sendable, CustomStringConvertible { case disconnected @@ -74,16 +106,32 @@ public actor RealtimeClientV2 { statusEventEmitter.attach(listener) } - public init(url: URL, config: RealtimeClientOptions) { + @available(*, deprecated, message: "Use RealtimeClientV2.init(url:options) instead.") + public init(config: Configuration) { + self.init( + url: config.url, + options: RealtimeClientOptions( + headers: config.headers, + heartbeatInterval: config.heartbeatInterval, + reconnectDelay: config.reconnectDelay, + timeoutInterval: config.timeoutInterval, + disconnectOnSessionLoss: config.disconnectOnSessionLoss, + connectOnSubscribe: config.connectOnSubscribe, + logger: config.logger + ) + ) + } + + public init(url: URL, options: RealtimeClientOptions) { self.init( url: url, - options: config, + options: options, ws: WebSocket( realtimeURL: Self.realtimeWebSocketURL( baseURL: Self.realtimeBaseURL(url: url), - apikey: config.apikey + apikey: options.apikey ), - options: config + options: options ) ) } diff --git a/Sources/Supabase/SupabaseClient.swift b/Sources/Supabase/SupabaseClient.swift index 20c14105..788fc65a 100644 --- a/Sources/Supabase/SupabaseClient.swift +++ b/Sources/Supabase/SupabaseClient.swift @@ -153,7 +153,7 @@ public final class SupabaseClient: @unchecked Sendable { realtimeV2 = RealtimeClientV2( url: supabaseURL.appendingPathComponent("/realtime/v1"), - config: realtimeOptions + options: realtimeOptions ) listenForAuthEvents() diff --git a/Sources/Supabase/Types.swift b/Sources/Supabase/Types.swift index b0439fef..6fa1edc6 100644 --- a/Sources/Supabase/Types.swift +++ b/Sources/Supabase/Types.swift @@ -13,7 +13,7 @@ public struct SupabaseClientOptions: Sendable { public let auth: AuthOptions public let global: GlobalOptions public let functions: FunctionsOptions - public let realtime: RealtimeClientV2.Configuration + public let realtime: RealtimeClientOptions public struct DatabaseOptions: Sendable { /// The Postgres schema which your tables belong to. Must be on the list of exposed schemas in @@ -109,7 +109,7 @@ public struct SupabaseClientOptions: Sendable { auth: AuthOptions, global: GlobalOptions = .init(), functions: FunctionsOptions = .init(), - realtime: RealtimeClientV2.Configuration = .init() + realtime: RealtimeClientOptions = .init() ) { self.db = db self.auth = auth @@ -125,7 +125,7 @@ extension SupabaseClientOptions { db: DatabaseOptions = .init(), global: GlobalOptions = .init(), functions: FunctionsOptions = .init(), - realtime: RealtimeClientV2.Configuration = .init() + realtime: RealtimeClientOptions = .init() ) { self.db = db auth = .init() From 17d27a5063edf601a82cb67a6165f9e2a6bbcfb0 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Mon, 13 May 2024 16:06:53 -0300 Subject: [PATCH 6/9] use renamed for deprecation message --- Sources/Realtime/V2/RealtimeClientV2.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Realtime/V2/RealtimeClientV2.swift b/Sources/Realtime/V2/RealtimeClientV2.swift index ceddbef0..3baba0dc 100644 --- a/Sources/Realtime/V2/RealtimeClientV2.swift +++ b/Sources/Realtime/V2/RealtimeClientV2.swift @@ -106,7 +106,7 @@ public actor RealtimeClientV2 { statusEventEmitter.attach(listener) } - @available(*, deprecated, message: "Use RealtimeClientV2.init(url:options) instead.") + @available(*, deprecated, renamed: "RealtimeClientV2.init(url:options:)") public init(config: Configuration) { self.init( url: config.url, From ec36b7857c8213d6c0bafd6643048e5fb9e9256c Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Mon, 13 May 2024 16:09:54 -0300 Subject: [PATCH 7/9] merge headers in-place --- Sources/Supabase/SupabaseClient.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Supabase/SupabaseClient.swift b/Sources/Supabase/SupabaseClient.swift index 788fc65a..16bd13d9 100644 --- a/Sources/Supabase/SupabaseClient.swift +++ b/Sources/Supabase/SupabaseClient.swift @@ -145,7 +145,7 @@ public final class SupabaseClient: @unchecked Sendable { ) var realtimeOptions = options.realtime - realtimeOptions.headers = realtimeOptions.headers.merged(with: defaultHeaders) + realtimeOptions.headers.merge(with: defaultHeaders) if realtimeOptions.logger == nil { realtimeOptions.logger = options.global.logger From 251a3ab92ba9608ae68f5bc45dacb5acec2d84c3 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Mon, 13 May 2024 16:25:34 -0300 Subject: [PATCH 8/9] fix realtime integration tests --- Tests/IntegrationTests/RealtimeIntegrationTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/IntegrationTests/RealtimeIntegrationTests.swift b/Tests/IntegrationTests/RealtimeIntegrationTests.swift index 5c365863..6c18686b 100644 --- a/Tests/IntegrationTests/RealtimeIntegrationTests.swift +++ b/Tests/IntegrationTests/RealtimeIntegrationTests.swift @@ -22,7 +22,7 @@ struct Logger: SupabaseLogger { final class RealtimeIntegrationTests: XCTestCase { let realtime = RealtimeClientV2( url: URL(string: "\(DotEnv.SUPABASE_URL)/realtime/v1")!, - config: RealtimeClientV2.Configuration( + options: RealtimeClientOptions( headers: ["apikey": DotEnv.SUPABASE_ANON_KEY], logger: Logger() ) From 4eccbb45419e9cb3c487ced08f20cc93254af608 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Mon, 13 May 2024 17:53:00 -0300 Subject: [PATCH 9/9] test logger instance --- Tests/SupabaseTests/SupabaseClientTests.swift | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/Tests/SupabaseTests/SupabaseClientTests.swift b/Tests/SupabaseTests/SupabaseClientTests.swift index b98755c3..cfb97000 100644 --- a/Tests/SupabaseTests/SupabaseClientTests.swift +++ b/Tests/SupabaseTests/SupabaseClientTests.swift @@ -17,6 +17,13 @@ final class AuthLocalStorageMock: AuthLocalStorage { final class SupabaseClientTests: XCTestCase { func testClientInitialization() async { + final class Logger: SupabaseLogger { + func log(message _: SupabaseLogMessage) { + // no-op + } + } + + let logger = Logger() let customSchema = "custom_schema" let localStorage = AuthLocalStorageMock() let customHeaders = ["header_field": "header_value"] @@ -29,7 +36,8 @@ final class SupabaseClientTests: XCTestCase { auth: SupabaseClientOptions.AuthOptions(storage: localStorage), global: SupabaseClientOptions.GlobalOptions( headers: customHeaders, - session: .shared + session: .shared, + logger: logger ), functions: SupabaseClientOptions.FunctionsOptions( region: .apNortheast1 @@ -67,6 +75,7 @@ final class SupabaseClientTests: XCTestCase { let realtimeOptions = await client.realtimeV2.options let expectedRealtimeHeader = client.defaultHeaders.merged(with: ["custom_realtime_header_key": "custom_realtime_header_value"]) XCTAssertNoDifference(realtimeOptions.headers, expectedRealtimeHeader) + XCTAssertIdentical(realtimeOptions.logger as? Logger, logger) } #if !os(Linux)