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/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 e88cda5f..3baba0dc 100644 --- a/Sources/Realtime/V2/RealtimeClientV2.swift +++ b/Sources/Realtime/V2/RealtimeClientV2.swift @@ -16,6 +16,7 @@ import Foundation public typealias JSONObject = _Helpers.JSONObject public actor RealtimeClientV2 { + @available(*, deprecated, renamed: "RealtimeClientOptions") public struct Configuration: Sendable { var url: URL var apiKey: String @@ -64,10 +65,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? @@ -79,34 +82,66 @@ 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) } + @available(*, deprecated, renamed: "RealtimeClientV2.init(url:options:)") public init(config: Configuration) { - self.init(config: config, ws: WebSocket(config: config)) + 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 + ) + ) } - init(config: Configuration, ws: any WebSocketClient) { - self.config = config - self.ws = ws + public init(url: URL, options: RealtimeClientOptions) { + self.init( + url: url, + options: options, + ws: WebSocket( + realtimeURL: Self.realtimeWebSocketURL( + baseURL: Self.realtimeBaseURL(url: url), + apikey: options.apikey + ), + options: options + ) + ) + } - if let customJWT = config.headers["Authorization"]?.split(separator: " ").last { - accessToken = String(customJWT) - } else { - accessToken = config.apiKey - } + init(url: URL, options: RealtimeClientOptions, ws: any WebSocketClient) { + self.url = url + self.options = options + self.ws = ws + accessToken = options.accessToken ?? options.apikey + apikey = options.apikey } deinit { @@ -126,16 +161,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 } @@ -165,7 +200,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 { @@ -174,17 +209,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() } @@ -208,7 +243,7 @@ public actor RealtimeClientV2 { topic: "realtime:\(topic)", config: config, socket: self, - logger: self.config.logger + logger: self.options.logger ) } @@ -224,7 +259,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() } } @@ -254,8 +289,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() } @@ -263,9 +298,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 } @@ -277,7 +312,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 @@ -297,7 +332,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() @@ -323,9 +358,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) } @@ -335,14 +370,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) @@ -356,10 +391,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 } @@ -377,21 +410,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 0bf2f4e0..16bd13d9 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,10 +100,8 @@ 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, @@ -118,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, @@ -141,17 +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.merge(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, - logger: options.global.logger - ) + url: supabaseURL.appendingPathComponent("/realtime/v1"), + options: realtimeOptions ) listenForAuthEvents() diff --git a/Sources/Supabase/Types.swift b/Sources/Supabase/Types.swift index ff937792..6fa1edc6 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: RealtimeClientOptions 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: RealtimeClientOptions = .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: RealtimeClientOptions = .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..6c18686b 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( - config: RealtimeClientV2.Configuration( - url: URL(string: "\(DotEnv.SUPABASE_URL)/realtime/v1")!, - apiKey: DotEnv.SUPABASE_ANON_KEY, + url: URL(string: "\(DotEnv.SUPABASE_URL)/realtime/v1")!, + options: RealtimeClientOptions( + 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..cfb97000 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 {} @@ -16,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"] @@ -28,10 +36,14 @@ 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 + ), + realtime: RealtimeClientOptions( + headers: ["custom_realtime_header_key": "custom_realtime_header_value"] ) ) ) @@ -55,8 +67,15 @@ 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) + XCTAssertIdentical(realtimeOptions.logger as? Logger, logger) } #if !os(Linux)