From 4b76069e9671db788011c6ee79642a96b5eac26b Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Tue, 21 Oct 2025 15:35:32 -0300 Subject: [PATCH 1/6] feat: improve FunctionRegion type safety and API consistency (#759) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: improve FunctionRegion type safety and API consistency - Change FunctionRegion from enum to struct with RawRepresentable - Add ExpressibleByStringLiteral conformance for better ergonomics - Update FunctionsClient to use FunctionRegion? instead of String? - Remove duplicate initializers and @_disfavoredOverload annotations - Update tests to work with new type structure This change improves type safety while maintaining backward compatibility and provides a cleaner API surface for function region configuration. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude # Conflicts: # Sources/Functions/FunctionsClient.swift * test: add test for ExpressibleByLiteralString * fix region * fix tests --- Sources/Functions/FunctionsClient.swift | 30 ++----- Sources/Functions/Types.swift | 79 ++++++------------- Sources/Supabase/Types.swift | 9 +-- .../FunctionsTests/FunctionsClientTests.swift | 26 +++++- Tests/SupabaseTests/SupabaseClientTests.swift | 2 +- 5 files changed, 58 insertions(+), 88 deletions(-) diff --git a/Sources/Functions/FunctionsClient.swift b/Sources/Functions/FunctionsClient.swift index fa6dc135d..f0037d106 100644 --- a/Sources/Functions/FunctionsClient.swift +++ b/Sources/Functions/FunctionsClient.swift @@ -24,7 +24,7 @@ public final class FunctionsClient: Sendable { let url: URL /// The Region to invoke the functions in. - let region: String? + let region: FunctionRegion? struct MutableState { /// Headers to be included in the requests. @@ -51,7 +51,7 @@ public final class FunctionsClient: Sendable { public convenience init( url: URL, headers: [String: String] = [:], - region: String? = nil, + region: FunctionRegion? = nil, logger: (any SupabaseLogger)? = nil, fetch: @escaping FetchHandler = { try await URLSession.shared.data(for: $0) } ) { @@ -68,7 +68,7 @@ public final class FunctionsClient: Sendable { convenience init( url: URL, headers: [String: String] = [:], - region: String? = nil, + region: FunctionRegion? = nil, logger: (any SupabaseLogger)? = nil, fetch: @escaping FetchHandler = { try await URLSession.shared.data(for: $0) }, sessionConfiguration: URLSessionConfiguration @@ -92,7 +92,7 @@ public final class FunctionsClient: Sendable { init( url: URL, headers: [String: String], - region: String?, + region: FunctionRegion?, http: any HTTPClientType, sessionConfiguration: URLSessionConfiguration = .default ) { @@ -109,24 +109,6 @@ public final class FunctionsClient: Sendable { } } - /// Initializes a new instance of `FunctionsClient`. - /// - /// - Parameters: - /// - url: The base URL for the functions. - /// - headers: Headers to be included in the requests. (Default: empty dictionary) - /// - region: The Region to invoke the functions in. - /// - logger: SupabaseLogger instance to use. - /// - fetch: The fetch handler used to make requests. (Default: URLSession.shared.data(for:)) - public convenience init( - url: URL, - headers: [String: String] = [:], - region: FunctionRegion? = nil, - logger: (any SupabaseLogger)? = nil, - fetch: @escaping FetchHandler = { try await URLSession.shared.data(for: $0) } - ) { - self.init(url: url, headers: headers, region: region?.rawValue, logger: logger, fetch: fetch) - } - /// Updates the authorization header. /// /// - Parameter token: The new JWT token sent in the authorization header. @@ -257,8 +239,8 @@ public final class FunctionsClient: Sendable { ) if let region = options.region ?? region { - request.headers[.xRegion] = region - query.appendOrUpdate(URLQueryItem(name: "forceFunctionRegion", value: region)) + request.headers[.xRegion] = region.rawValue + query.appendOrUpdate(URLQueryItem(name: "forceFunctionRegion", value: region.rawValue)) request.query = query } diff --git a/Sources/Functions/Types.swift b/Sources/Functions/Types.swift index e53f06fdd..8cf15c90c 100644 --- a/Sources/Functions/Types.swift +++ b/Sources/Functions/Types.swift @@ -28,7 +28,7 @@ public struct FunctionInvokeOptions: Sendable { /// Body data to be sent with the function invocation. let body: Data? /// The Region to invoke the function in. - let region: String? + let region: FunctionRegion? /// The query to be included in the function invocation. let query: [URLQueryItem] @@ -40,12 +40,11 @@ public struct FunctionInvokeOptions: Sendable { /// - headers: Headers to be included in the function invocation. (Default: empty dictionary) /// - region: The Region to invoke the function in. /// - body: The body data to be sent with the function invocation. (Default: nil) - @_disfavoredOverload public init( method: Method? = nil, query: [URLQueryItem] = [], headers: [String: String] = [:], - region: String? = nil, + region: FunctionRegion? = nil, body: some Encodable ) { var defaultHeaders = HTTPFields() @@ -76,12 +75,11 @@ public struct FunctionInvokeOptions: Sendable { /// - query: The query to be included in the function invocation. /// - headers: Headers to be included in the function invocation. (Default: empty dictionary) /// - region: The Region to invoke the function in. - @_disfavoredOverload public init( method: Method? = nil, query: [URLQueryItem] = [], headers: [String: String] = [:], - region: String? = nil + region: FunctionRegion? = nil ) { self.method = method self.headers = HTTPFields(headers) @@ -116,56 +114,29 @@ public struct FunctionInvokeOptions: Sendable { } } -public enum FunctionRegion: String, Sendable { - case apNortheast1 = "ap-northeast-1" - case apNortheast2 = "ap-northeast-2" - case apSouth1 = "ap-south-1" - case apSoutheast1 = "ap-southeast-1" - case apSoutheast2 = "ap-southeast-2" - case caCentral1 = "ca-central-1" - case euCentral1 = "eu-central-1" - case euWest1 = "eu-west-1" - case euWest2 = "eu-west-2" - case euWest3 = "eu-west-3" - case saEast1 = "sa-east-1" - case usEast1 = "us-east-1" - case usWest1 = "us-west-1" - case usWest2 = "us-west-2" -} - -extension FunctionInvokeOptions { - /// Initializes the `FunctionInvokeOptions` structure. - /// - /// - Parameters: - /// - method: Method to use in the function invocation. - /// - headers: Headers to be included in the function invocation. (Default: empty dictionary) - /// - region: The Region to invoke the function in. - /// - body: The body data to be sent with the function invocation. (Default: nil) - public init( - method: Method? = nil, - headers: [String: String] = [:], - region: FunctionRegion? = nil, - body: some Encodable - ) { - self.init( - method: method, - headers: headers, - region: region?.rawValue, - body: body - ) +public struct FunctionRegion: RawRepresentable, Sendable { + public let rawValue: String + public init(rawValue: String) { + self.rawValue = rawValue } - /// Initializes the `FunctionInvokeOptions` structure. - /// - /// - Parameters: - /// - method: Method to use in the function invocation. - /// - headers: Headers to be included in the function invocation. (Default: empty dictionary) - /// - region: The Region to invoke the function in. - public init( - method: Method? = nil, - headers: [String: String] = [:], - region: FunctionRegion? = nil - ) { - self.init(method: method, headers: headers, region: region?.rawValue) + public static let apNortheast1 = FunctionRegion(rawValue: "ap-northeast-1") + public static let apNortheast2 = FunctionRegion(rawValue: "ap-northeast-2") + public static let apSouth1 = FunctionRegion(rawValue: "ap-south-1") + public static let apSoutheast1 = FunctionRegion(rawValue: "ap-southeast-1") + public static let apSoutheast2 = FunctionRegion(rawValue: "ap-southeast-2") + public static let caCentral1 = FunctionRegion(rawValue: "ca-central-1") + public static let euCentral1 = FunctionRegion(rawValue: "eu-central-1") + public static let euWest1 = FunctionRegion(rawValue: "eu-west-1") + public static let euWest2 = FunctionRegion(rawValue: "eu-west-2") + public static let euWest3 = FunctionRegion(rawValue: "eu-west-3") + public static let saEast1 = FunctionRegion(rawValue: "sa-east-1") + public static let usEast1 = FunctionRegion(rawValue: "us-east-1") + public static let usWest1 = FunctionRegion(rawValue: "us-west-1") + public static let usWest2 = FunctionRegion(rawValue: "us-west-2") +} +extension FunctionRegion: ExpressibleByStringLiteral { + public init(stringLiteral value: String) { + self.init(rawValue: value) } } diff --git a/Sources/Supabase/Types.swift b/Sources/Supabase/Types.swift index b567d7d34..61a7091b8 100644 --- a/Sources/Supabase/Types.swift +++ b/Sources/Supabase/Types.swift @@ -107,15 +107,10 @@ public struct SupabaseClientOptions: Sendable { public struct FunctionsOptions: Sendable { /// The Region to invoke the functions in. - public let region: String? - - @_disfavoredOverload - public init(region: String? = nil) { - self.region = region - } + public let region: FunctionRegion? public init(region: FunctionRegion? = nil) { - self.init(region: region?.rawValue) + self.region = region } } diff --git a/Tests/FunctionsTests/FunctionsClientTests.swift b/Tests/FunctionsTests/FunctionsClientTests.swift index 652a1a9f8..945b5101d 100644 --- a/Tests/FunctionsTests/FunctionsClientTests.swift +++ b/Tests/FunctionsTests/FunctionsClientTests.swift @@ -31,7 +31,7 @@ final class FunctionsClientTests: XCTestCase { headers: [ "apikey": apiKey ], - region: region, + region: region.flatMap(FunctionRegion.init(rawValue:)), fetch: { request in try await self.session.data(for: request) }, @@ -49,7 +49,7 @@ final class FunctionsClientTests: XCTestCase { headers: ["apikey": apiKey], region: .saEast1 ) - XCTAssertEqual(client.region, "sa-east-1") + XCTAssertEqual(client.region?.rawValue, "sa-east-1") XCTAssertEqual(client.headers[.init("apikey")!], apiKey) XCTAssertNotNil(client.headers[.init("X-Client-Info")!]) @@ -205,6 +205,28 @@ final class FunctionsClientTests: XCTestCase { try await sut.invoke("hello-world", options: .init(region: .caCentral1)) } + func testInvokeWithRegion_usingExpressibleByLiteral() async throws { + Mock( + url: url.appendingPathComponent("hello-world"), + ignoreQuery: true, + statusCode: 200, + data: [.post: Data()] + ) + .snapshotRequest { + #""" + curl \ + --request POST \ + --header "X-Client-Info: functions-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + --header "x-region: ca-central-1" \ + "http://localhost:5432/functions/v1/hello-world?forceFunctionRegion=ca-central-1" + """# + } + .register() + + try await sut.invoke("hello-world", options: .init(region: "ca-central-1")) + } + func testInvokeWithoutRegion() async throws { region = nil diff --git a/Tests/SupabaseTests/SupabaseClientTests.swift b/Tests/SupabaseTests/SupabaseClientTests.swift index 437353cd6..862d6b24a 100644 --- a/Tests/SupabaseTests/SupabaseClientTests.swift +++ b/Tests/SupabaseTests/SupabaseClientTests.swift @@ -81,7 +81,7 @@ final class SupabaseClientTests: XCTestCase { expectNoDifference(client.headers, client.storage.configuration.headers) expectNoDifference(client.headers, client.rest.configuration.headers) - XCTAssertEqual(client.functions.region, "ap-northeast-1") + XCTAssertEqual(client.functions.region?.rawValue, "ap-northeast-1") let realtimeURL = client.realtimeV2.url XCTAssertEqual(realtimeURL.absoluteString, "https://project-ref.supabase.co/realtime/v1") From 57a55bcde64794d182e35a6ee93ded0bb2fc76b1 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Tue, 21 Oct 2025 16:24:07 -0300 Subject: [PATCH 2/6] feat(functions): make `FunctionsClient` an actor (#831) * feat(functions): make FunctionsClient and actor * drop disfavoredOverload --- Sources/Functions/FunctionsClient.swift | 58 +++++++++---------- .../xcshareddata/xcschemes/Functions.xcscheme | 10 ++++ .../FunctionsTests/FunctionsClientTests.swift | 34 +++++------ Tests/SupabaseTests/SupabaseClientTests.swift | 7 ++- 4 files changed, 58 insertions(+), 51 deletions(-) diff --git a/Sources/Functions/FunctionsClient.swift b/Sources/Functions/FunctionsClient.swift index f0037d106..00590bc6e 100644 --- a/Sources/Functions/FunctionsClient.swift +++ b/Sources/Functions/FunctionsClient.swift @@ -1,4 +1,3 @@ -import ConcurrencyExtras import Foundation import HTTPTypes @@ -9,11 +8,12 @@ import HTTPTypes let version = Helpers.version /// An actor representing a client for invoking functions. -public final class FunctionsClient: Sendable { +public actor FunctionsClient { /// Fetch handler used to make requests. - public typealias FetchHandler = @Sendable (_ request: URLRequest) async throws -> ( - Data, URLResponse - ) + public typealias FetchHandler = + @Sendable (_ request: URLRequest) async throws -> ( + Data, URLResponse + ) /// Request idle timeout: 150s (If an Edge Function doesn't send a response before the timeout, 504 Gateway Timeout will be returned) /// @@ -26,19 +26,12 @@ public final class FunctionsClient: Sendable { /// The Region to invoke the functions in. let region: FunctionRegion? - struct MutableState { - /// Headers to be included in the requests. - var headers = HTTPFields() - } + /// Headers to be included in the requests. + var headers = HTTPFields() private let http: any HTTPClientType - private let mutableState = LockIsolated(MutableState()) private let sessionConfiguration: URLSessionConfiguration - var headers: HTTPFields { - mutableState.headers - } - /// Initializes a new instance of `FunctionsClient`. /// /// - Parameters: @@ -47,8 +40,7 @@ public final class FunctionsClient: Sendable { /// - region: The Region to invoke the functions in. /// - logger: SupabaseLogger instance to use. /// - fetch: The fetch handler used to make requests. (Default: URLSession.shared.data(for:)) - @_disfavoredOverload - public convenience init( + public init( url: URL, headers: [String: String] = [:], region: FunctionRegion? = nil, @@ -65,7 +57,7 @@ public final class FunctionsClient: Sendable { ) } - convenience init( + init( url: URL, headers: [String: String] = [:], region: FunctionRegion? = nil, @@ -101,11 +93,9 @@ public final class FunctionsClient: Sendable { self.http = http self.sessionConfiguration = sessionConfiguration - mutableState.withValue { - $0.headers = HTTPFields(headers) - if $0.headers[.xClientInfo] == nil { - $0.headers[.xClientInfo] = "functions-swift/\(version)" - } + self.headers = HTTPFields(headers) + if self.headers[.xClientInfo] == nil { + self.headers[.xClientInfo] = "functions-swift/\(version)" } } @@ -113,12 +103,10 @@ public final class FunctionsClient: Sendable { /// /// - Parameter token: The new JWT token sent in the authorization header. public func setAuth(token: String?) { - mutableState.withValue { - if let token { - $0.headers[.authorization] = "Bearer \(token)" - } else { - $0.headers[.authorization] = nil - } + if let token { + headers[.authorization] = "Bearer \(token)" + } else { + headers[.authorization] = nil } } @@ -136,7 +124,8 @@ public final class FunctionsClient: Sendable { decode: (Data, HTTPURLResponse) throws -> Response ) async throws -> Response { let response = try await rawInvoke( - functionName: functionName, invokeOptions: options + functionName: functionName, + invokeOptions: options ) return try decode(response.data, response.underlyingResponse) } @@ -208,7 +197,10 @@ public final class FunctionsClient: Sendable { let delegate = StreamResponseDelegate(continuation: continuation) let session = URLSession( - configuration: sessionConfiguration, delegate: delegate, delegateQueue: nil) + configuration: sessionConfiguration, + delegate: delegate, + delegateQueue: nil + ) let urlRequest = buildRequest(functionName: functionName, options: invokeOptions).urlRequest @@ -233,7 +225,7 @@ public final class FunctionsClient: Sendable { url: url.appendingPathComponent(functionName), method: FunctionInvokeOptions.httpMethod(options.method) ?? .post, query: query, - headers: mutableState.headers.merging(with: options.headers), + headers: headers.merging(with: options.headers), body: options.body, timeoutInterval: FunctionsClient.requestIdleTimeout ) @@ -264,7 +256,9 @@ final class StreamResponseDelegate: NSObject, URLSessionDataDelegate, Sendable { } func urlSession( - _: URLSession, dataTask _: URLSessionDataTask, didReceive response: URLResponse, + _: URLSession, + dataTask _: URLSessionDataTask, + didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void ) { defer { diff --git a/Supabase.xcworkspace/xcshareddata/xcschemes/Functions.xcscheme b/Supabase.xcworkspace/xcshareddata/xcschemes/Functions.xcscheme index 91fc09e07..5ff3d456a 100644 --- a/Supabase.xcworkspace/xcshareddata/xcschemes/Functions.xcscheme +++ b/Supabase.xcworkspace/xcshareddata/xcschemes/Functions.xcscheme @@ -28,6 +28,16 @@ selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES"> + + + + Date: Tue, 21 Oct 2025 17:31:07 -0300 Subject: [PATCH 3/6] feat: set supported platforms as iOS 16, macOS 13, watchOS 9, and tvOS 16 (#832) --- Package.swift | 10 ++--- Package@swift-6.1.swift | 10 ++--- README.md | 2 +- Sources/Auth/AuthClient.swift | 1 - .../Helpers/Logger/OSLogSupabaseLogger.swift | 1 - Sources/Helpers/Task+withTimeout.swift | 2 +- Sources/Helpers/_Clock.swift | 39 ++----------------- Sources/Realtime/RealtimeChannelV2.swift | 2 +- Sources/Realtime/RealtimeClientV2.swift | 4 +- 9 files changed, 18 insertions(+), 53 deletions(-) diff --git a/Package.swift b/Package.swift index 86bffd635..ade0d0fd2 100644 --- a/Package.swift +++ b/Package.swift @@ -7,11 +7,11 @@ import PackageDescription let package = Package( name: "Supabase", platforms: [ - .iOS(.v13), - .macCatalyst(.v13), - .macOS(.v10_15), - .watchOS(.v6), - .tvOS(.v13), + .iOS(.v16), + .macCatalyst(.v16), + .macOS(.v13), + .watchOS(.v9), + .tvOS(.v16), ], products: [ .library(name: "Auth", targets: ["Auth"]), diff --git a/Package@swift-6.1.swift b/Package@swift-6.1.swift index 7a2612388..75049b7d8 100644 --- a/Package@swift-6.1.swift +++ b/Package@swift-6.1.swift @@ -7,11 +7,11 @@ import PackageDescription let package = Package( name: "Supabase", platforms: [ - .iOS(.v13), - .macCatalyst(.v13), - .macOS(.v10_15), - .watchOS(.v6), - .tvOS(.v13), + .iOS(.v16), + .macCatalyst(.v16), + .macOS(.v13), + .watchOS(.v9), + .tvOS(.v16), ], products: [ .library(name: "Auth", targets: ["Auth"]), diff --git a/README.md b/README.md index 57a9f03e0..f02defb57 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Supabase SDK for Swift. Mirrors the design of [supabase-js](https://github.com/s ## Usage ### Requirements -- iOS 13.0+ / macOS 10.15+ / tvOS 13+ / watchOS 6+ / visionOS 1+ +- iOS 16.0+ / macOS 13+ / tvOS 16+ / watchOS 9+ / visionOS 1+ - Xcode 15.3+ - Swift 5.10+ diff --git a/Sources/Auth/AuthClient.swift b/Sources/Auth/AuthClient.swift index 5d8f80249..8a602d246 100644 --- a/Sources/Auth/AuthClient.swift +++ b/Sources/Auth/AuthClient.swift @@ -702,7 +702,6 @@ public actor AuthClient { /// - Note: This method support the PKCE flow. /// - Warning: Do not call `start()` on the `ASWebAuthenticationSession` object inside the /// `configure` closure, as the method implementation calls it already. - @available(watchOS 6.2, tvOS 16.0, *) @discardableResult public func signInWithOAuth( provider: Provider, diff --git a/Sources/Helpers/Logger/OSLogSupabaseLogger.swift b/Sources/Helpers/Logger/OSLogSupabaseLogger.swift index 8b9233cf2..ff7e7d0d5 100644 --- a/Sources/Helpers/Logger/OSLogSupabaseLogger.swift +++ b/Sources/Helpers/Logger/OSLogSupabaseLogger.swift @@ -23,7 +23,6 @@ import Foundation /// options: .init(global: .init(logger: supabaseLogger)) /// ) /// ``` - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) public struct OSLogSupabaseLogger: SupabaseLogger { private let logger: Logger diff --git a/Sources/Helpers/Task+withTimeout.swift b/Sources/Helpers/Task+withTimeout.swift index 80ab8fb78..5b3ab9995 100644 --- a/Sources/Helpers/Task+withTimeout.swift +++ b/Sources/Helpers/Task+withTimeout.swift @@ -26,7 +26,7 @@ package func withTimeout( group.addTask { let interval = deadline.timeIntervalSinceNow if interval > 0 { - try await _clock.sleep(for: interval) + try await _clock.sleep(for: .seconds(interval)) } try Task.checkCancellation() throw TimeoutError() diff --git a/Sources/Helpers/_Clock.swift b/Sources/Helpers/_Clock.swift index 765565e1e..8e26a4d14 100644 --- a/Sources/Helpers/_Clock.swift +++ b/Sources/Helpers/_Clock.swift @@ -9,43 +9,10 @@ import Clocks import ConcurrencyExtras import Foundation -package protocol _Clock: Sendable { - func sleep(for duration: TimeInterval) async throws -} - -@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) -extension ContinuousClock: _Clock { - package func sleep(for duration: TimeInterval) async throws { - try await sleep(for: .seconds(duration)) - } -} -@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) -extension TestClock: _Clock { - package func sleep(for duration: TimeInterval) async throws { - try await sleep(for: .seconds(duration)) - } -} - -/// `_Clock` used on platforms where ``Clock`` protocol isn't available. -struct FallbackClock: _Clock { - func sleep(for duration: TimeInterval) async throws { - try await Task.sleep(nanoseconds: NSEC_PER_SEC * UInt64(duration)) - } -} - -// Resolves clock instance based on platform availability. -let _resolveClock: @Sendable () -> any _Clock = { - if #available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) { - ContinuousClock() - } else { - FallbackClock() - } -} - -private let __clock = LockIsolated(_resolveClock()) +private let __clock: LockIsolated> = LockIsolated(ContinuousClock()) #if DEBUG - package var _clock: any _Clock { + package var _clock: any Clock { get { __clock.value } @@ -54,7 +21,7 @@ private let __clock = LockIsolated(_resolveClock()) } } #else - package var _clock: any _Clock { + package var _clock: any Clock { __clock.value } #endif diff --git a/Sources/Realtime/RealtimeChannelV2.swift b/Sources/Realtime/RealtimeChannelV2.swift index 2be951d93..f4a9744b1 100644 --- a/Sources/Realtime/RealtimeChannelV2.swift +++ b/Sources/Realtime/RealtimeChannelV2.swift @@ -137,7 +137,7 @@ public final class RealtimeChannelV2: Sendable, RealtimeChannelProtocol { ) do { - try await _clock.sleep(for: delay) + try await _clock.sleep(for: .seconds(delay)) } catch { // If sleep is cancelled, break out of retry loop logger?.debug("Subscription retry cancelled for channel '\(topic)'") diff --git a/Sources/Realtime/RealtimeClientV2.swift b/Sources/Realtime/RealtimeClientV2.swift index a6041d490..a621b302b 100644 --- a/Sources/Realtime/RealtimeClientV2.swift +++ b/Sources/Realtime/RealtimeClientV2.swift @@ -187,7 +187,7 @@ public final class RealtimeClientV2: Sendable, RealtimeClientProtocol { if status == .disconnected { let connectionTask = Task { if reconnect { - try? await _clock.sleep(for: options.reconnectDelay) + try? await _clock.sleep(for: .seconds(options.reconnectDelay)) if Task.isCancelled { options.logger?.debug("Reconnect cancelled, returning") @@ -398,7 +398,7 @@ public final class RealtimeClientV2: Sendable, RealtimeClientProtocol { private func startHeartbeating() { let heartbeatTask = Task { [weak self, options] in while !Task.isCancelled { - try? await _clock.sleep(for: options.heartbeatInterval) + try? await _clock.sleep(for: .seconds(options.heartbeatInterval)) if Task.isCancelled { break } From 8f78fe0629a488ee45c5f2c6f57fbafd04166ed9 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Tue, 21 Oct 2025 18:00:46 -0300 Subject: [PATCH 4/6] feat(realtime): drop deprecated realtime (#834) --- Sources/Realtime/Deprecated/Defaults.swift | 108 -- Sources/Realtime/Deprecated/Delegated.swift | 102 -- Sources/Realtime/Deprecated/Deprecated.swift | 80 -- .../Realtime/Deprecated/HeartbeatTimer.swift | 136 --- .../Deprecated/PhoenixTransport.swift | 316 ----- Sources/Realtime/Deprecated/Presence.swift | 417 ------- Sources/Realtime/Deprecated/Push.swift | 265 ---- .../Realtime/Deprecated/RealtimeChannel.swift | 1037 ---------------- .../Realtime/Deprecated/RealtimeClient.swift | 1071 ----------------- .../Realtime/Deprecated/RealtimeMessage.swift | 86 -- .../Realtime/Deprecated/TimeoutTimer.swift | 108 -- Sources/Realtime/Types.swift | 26 + Sources/Supabase/Deprecated.swift | 6 - Sources/Supabase/SupabaseClient.swift | 13 +- 14 files changed, 27 insertions(+), 3744 deletions(-) delete mode 100644 Sources/Realtime/Deprecated/Defaults.swift delete mode 100644 Sources/Realtime/Deprecated/Delegated.swift delete mode 100644 Sources/Realtime/Deprecated/Deprecated.swift delete mode 100644 Sources/Realtime/Deprecated/HeartbeatTimer.swift delete mode 100644 Sources/Realtime/Deprecated/PhoenixTransport.swift delete mode 100644 Sources/Realtime/Deprecated/Presence.swift delete mode 100644 Sources/Realtime/Deprecated/Push.swift delete mode 100644 Sources/Realtime/Deprecated/RealtimeChannel.swift delete mode 100644 Sources/Realtime/Deprecated/RealtimeClient.swift delete mode 100644 Sources/Realtime/Deprecated/RealtimeMessage.swift delete mode 100644 Sources/Realtime/Deprecated/TimeoutTimer.swift diff --git a/Sources/Realtime/Deprecated/Defaults.swift b/Sources/Realtime/Deprecated/Defaults.swift deleted file mode 100644 index e74f08bc7..000000000 --- a/Sources/Realtime/Deprecated/Defaults.swift +++ /dev/null @@ -1,108 +0,0 @@ -// Copyright (c) 2021 David Stump -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -// THE SOFTWARE. - -import Foundation - -/// A collection of default values and behaviors used across the Client -public enum Defaults { - /// Default timeout when sending messages - public static let timeoutInterval: TimeInterval = 10.0 - - /// Default interval to send heartbeats on - public static let heartbeatInterval: TimeInterval = 30.0 - - /// Default maximum amount of time which the system may delay heartbeat events in order to - /// minimize power usage - public static let heartbeatLeeway: DispatchTimeInterval = .milliseconds(10) - - /// Default reconnect algorithm for the socket - public static let reconnectSteppedBackOff: (Int) -> TimeInterval = { tries in - tries > 9 ? 5.0 : [0.01, 0.05, 0.1, 0.15, 0.2, 0.25, 0.5, 1.0, 2.0][tries - 1] - } - - /** Default rejoin algorithm for individual channels */ - public static let rejoinSteppedBackOff: (Int) -> TimeInterval = { tries in - tries > 3 ? 10 : [1, 2, 5][tries - 1] - } - - public static let vsn = "2.0.0" - - /// Default encode function, utilizing JSONSerialization.data - public static let encode: (Any) -> Data = { json in - try! JSONSerialization - .data( - withJSONObject: json, - options: JSONSerialization.WritingOptions() - ) - } - - /// Default decode function, utilizing JSONSerialization.jsonObject - public static let decode: (Data) -> Any? = { data in - guard - let json = - try? JSONSerialization - .jsonObject( - with: data, - options: JSONSerialization.ReadingOptions() - ) - else { return nil } - return json - } - - public static let heartbeatQueue: DispatchQueue = .init( - label: "com.phoenix.socket.heartbeat" - ) -} - -/// Represents the multiple states that a Channel can be in -/// throughout it's lifecycle. -public enum ChannelState: String { - case closed - case errored - case joined - case joining - case leaving -} - -/// Represents the different events that can be sent through -/// a channel regarding a Channel's lifecycle. -public enum ChannelEvent { - public static let join = "phx_join" - public static let leave = "phx_leave" - public static let close = "phx_close" - public static let error = "phx_error" - public static let reply = "phx_reply" - public static let system = "system" - public static let broadcast = "broadcast" - public static let accessToken = "access_token" - public static let presence = "presence" - public static let presenceDiff = "presence_diff" - public static let presenceState = "presence_state" - public static let postgresChanges = "postgres_changes" - - public static let heartbeat = "heartbeat" - - static func isLifecyleEvent(_ event: String) -> Bool { - switch event { - case join, leave, reply, error, close: true - default: false - } - } -} diff --git a/Sources/Realtime/Deprecated/Delegated.swift b/Sources/Realtime/Deprecated/Delegated.swift deleted file mode 100644 index 6e5489140..000000000 --- a/Sources/Realtime/Deprecated/Delegated.swift +++ /dev/null @@ -1,102 +0,0 @@ -// Copyright (c) 2021 David Stump -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -// THE SOFTWARE. - -/// Provides a memory-safe way of passing callbacks around while not creating -/// retain cycles. This file was copied from https://github.com/dreymonde/Delegated -/// instead of added as a dependency to reduce the number of packages that -/// ship with SwiftPhoenixClient -public struct Delegated { - private(set) var callback: ((Input) -> Output?)? - - public init() {} - - public mutating func delegate( - to target: Target, - with callback: @escaping (Target, Input) -> Output - ) { - self.callback = { [weak target] input in - guard let target else { - return nil - } - return callback(target, input) - } - } - - public func call(_ input: Input) -> Output? { - callback?(input) - } - - public var isDelegateSet: Bool { - callback != nil - } -} - -extension Delegated { - public mutating func stronglyDelegate( - to target: Target, - with callback: @escaping (Target, Input) -> Output - ) { - self.callback = { input in - callback(target, input) - } - } - - public mutating func manuallyDelegate(with callback: @escaping (Input) -> Output) { - self.callback = callback - } - - public mutating func removeDelegate() { - callback = nil - } -} - -extension Delegated where Input == Void { - public mutating func delegate( - to target: Target, - with callback: @escaping (Target) -> Output - ) { - delegate(to: target, with: { target, _ in callback(target) }) - } - - public mutating func stronglyDelegate( - to target: Target, - with callback: @escaping (Target) -> Output - ) { - stronglyDelegate(to: target, with: { target, _ in callback(target) }) - } -} - -extension Delegated where Input == Void { - public func call() -> Output? { - call(()) - } -} - -extension Delegated where Output == Void { - public func call(_ input: Input) { - callback?(input) - } -} - -extension Delegated where Input == Void, Output == Void { - public func call() { - call(()) - } -} diff --git a/Sources/Realtime/Deprecated/Deprecated.swift b/Sources/Realtime/Deprecated/Deprecated.swift deleted file mode 100644 index c0cb2937b..000000000 --- a/Sources/Realtime/Deprecated/Deprecated.swift +++ /dev/null @@ -1,80 +0,0 @@ -// -// Deprecated.swift -// -// -// Created by Guilherme Souza on 23/12/23. -// - -import Foundation - -@available(*, deprecated, renamed: "RealtimeMessage") -public typealias Message = RealtimeMessage - -extension RealtimeClientV2 { - @available(*, deprecated, renamed: "channels") - public var subscriptions: [String: RealtimeChannelV2] { - channels - } - - @available(*, deprecated, renamed: "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 - } - } - - @available(*, deprecated, renamed: "RealtimeClientStatus") - public typealias Status = RealtimeClientStatus - - @available(*, deprecated, renamed: "RealtimeClientV2.init(url:options:)") - public convenience 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 - ) - ) - } -} - -extension RealtimeChannelV2 { - @available(*, deprecated, renamed: "RealtimeSubscription") - public typealias Subscription = ObservationToken - - @available(*, deprecated, renamed: "RealtimeChannelStatus") - public typealias Status = RealtimeChannelStatus -} diff --git a/Sources/Realtime/Deprecated/HeartbeatTimer.swift b/Sources/Realtime/Deprecated/HeartbeatTimer.swift deleted file mode 100644 index 7bd4ccbf0..000000000 --- a/Sources/Realtime/Deprecated/HeartbeatTimer.swift +++ /dev/null @@ -1,136 +0,0 @@ -// Copyright (c) 2021 David Stump -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -// THE SOFTWARE. - -import Foundation - -/** - Heartbeat Timer class which manages the lifecycle of the underlying - timer which triggers when a heartbeat should be fired. This heartbeat - runs on it's own Queue so that it does not interfere with the main - queue but guarantees thread safety. - */ - -class HeartbeatTimer { - // ---------------------------------------------------------------------- - - // MARK: - Dependencies - - // ---------------------------------------------------------------------- - // The interval to wait before firing the Timer - let timeInterval: TimeInterval - - /// The maximum amount of time which the system may delay the delivery of the timer events - let leeway: DispatchTimeInterval - - // The DispatchQueue to schedule the timers on - let queue: DispatchQueue - - // UUID which specifies the Timer instance. Verifies that timers are different - let uuid: String = UUID().uuidString - - // ---------------------------------------------------------------------- - - // MARK: - Properties - - // ---------------------------------------------------------------------- - // The underlying, cancelable, resettable, timer. - private var temporaryTimer: (any DispatchSourceTimer)? - // The event handler that is called by the timer when it fires. - private var temporaryEventHandler: (() -> Void)? - - /** - Create a new HeartbeatTimer - - - Parameters: - - timeInterval: Interval to fire the timer. Repeats - - queue: Queue to schedule the timer on - - leeway: The maximum amount of time which the system may delay the delivery of the timer events - */ - init( - timeInterval: TimeInterval, queue: DispatchQueue = Defaults.heartbeatQueue, - leeway: DispatchTimeInterval = Defaults.heartbeatLeeway - ) { - self.timeInterval = timeInterval - self.queue = queue - self.leeway = leeway - } - - /** - Create a new HeartbeatTimer - - - Parameter timeInterval: Interval to fire the timer. Repeats - */ - convenience init(timeInterval: TimeInterval) { - self.init(timeInterval: timeInterval, queue: Defaults.heartbeatQueue) - } - - func start(eventHandler: @escaping () -> Void) { - queue.sync { - // Create a new DispatchSourceTimer, passing the event handler - let timer = DispatchSource.makeTimerSource(flags: [], queue: queue) - timer.setEventHandler(handler: eventHandler) - - // Schedule the timer to first fire in `timeInterval` and then - // repeat every `timeInterval` - timer.schedule( - deadline: DispatchTime.now() + self.timeInterval, - repeating: self.timeInterval, - leeway: self.leeway - ) - - // Start the timer - timer.resume() - self.temporaryEventHandler = eventHandler - self.temporaryTimer = timer - } - } - - func stop() { - // Must be queued synchronously to prevent threading issues. - queue.sync { - // DispatchSourceTimer will automatically cancel when released - temporaryTimer = nil - temporaryEventHandler = nil - } - } - - /** - True if the Timer exists and has not been cancelled. False otherwise - */ - var isValid: Bool { - guard let timer = temporaryTimer else { return false } - return !timer.isCancelled - } - - /** - Calls the Timer's event handler immediately. This method - is primarily used in tests (not ideal) - */ - func fire() { - guard isValid else { return } - temporaryEventHandler?() - } -} - -extension HeartbeatTimer: Equatable { - static func == (lhs: HeartbeatTimer, rhs: HeartbeatTimer) -> Bool { - lhs.uuid == rhs.uuid - } -} diff --git a/Sources/Realtime/Deprecated/PhoenixTransport.swift b/Sources/Realtime/Deprecated/PhoenixTransport.swift deleted file mode 100644 index 79c854005..000000000 --- a/Sources/Realtime/Deprecated/PhoenixTransport.swift +++ /dev/null @@ -1,316 +0,0 @@ -// Copyright (c) 2021 David Stump -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -// THE SOFTWARE. - -import Foundation - -#if canImport(FoundationNetworking) - import FoundationNetworking -#endif - -// ---------------------------------------------------------------------- - -// MARK: - Transport Protocol - -// ---------------------------------------------------------------------- -/** - Defines a `Socket`'s Transport layer. - */ -// sourcery: AutoMockable -public protocol PhoenixTransport { - /// The current `ReadyState` of the `Transport` layer - var readyState: PhoenixTransportReadyState { get } - - /// Delegate for the `Transport` layer - var delegate: (any PhoenixTransportDelegate)? { get set } - - /** - Connect to the server - - - Parameters: - - headers: Headers to include in the URLRequests when opening the Websocket connection. Can be empty [:] - */ - func connect(with headers: [String: String]) - - /** - Disconnect from the server. - - - Parameters: - - code: Status code as defined by Section 7.4 of RFC 6455. - - reason: Reason why the connection is closing. Optional. - */ - func disconnect(code: Int, reason: String?) - - /** - Sends a message to the server. - - - Parameter data: Data to send. - */ - func send(data: Data) -} - -// ---------------------------------------------------------------------- - -// MARK: - Transport Delegate Protocol - -// ---------------------------------------------------------------------- -/// Delegate to receive notifications of events that occur in the `Transport` layer -public protocol PhoenixTransportDelegate { - /** - Notified when the `Transport` opens. - - - Parameter response: Response from the server indicating that the WebSocket handshake was successful and the connection has been upgraded to webSockets - */ - func onOpen(response: URLResponse?) - - /** - Notified when the `Transport` receives an error. - - - Parameter error: Client-side error from the underlying `Transport` implementation - - Parameter response: Response from the server, if any, that occurred with the Error - - */ - func onError(error: any Error, response: URLResponse?) - - /** - Notified when the `Transport` receives a message from the server. - - - Parameter message: Message received from the server - */ - func onMessage(message: String) - - /** - Notified when the `Transport` closes. - - - Parameter code: Code that was sent when the `Transport` closed - - Parameter reason: A concise human-readable prose explanation for the closure - */ - func onClose(code: Int, reason: String?) -} - -// ---------------------------------------------------------------------- - -// MARK: - Transport Ready State Enum - -// ---------------------------------------------------------------------- -/// Available `ReadyState`s of a `Transport` layer. -public enum PhoenixTransportReadyState { - /// The `Transport` is opening a connection to the server. - case connecting - - /// The `Transport` is connected to the server. - case open - - /// The `Transport` is closing the connection to the server. - case closing - - /// The `Transport` has disconnected from the server. - case closed -} - -// ---------------------------------------------------------------------- - -// MARK: - Default Websocket Transport Implementation - -// ---------------------------------------------------------------------- -/// A `Transport` implementation that relies on URLSession's native WebSocket -/// implementation. -/// -/// This implementation ships default with SwiftPhoenixClient however -/// SwiftPhoenixClient supports earlier OS versions using one of the submodule -/// `Transport` implementations. Or you can create your own implementation using -/// your own WebSocket library or implementation. -@available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *) -open class URLSessionTransport: NSObject, PhoenixTransport, URLSessionWebSocketDelegate { - /// The URL to connect to - let url: URL - - /// The URLSession configuration - let configuration: URLSessionConfiguration - - /// The underling URLSession. Assigned during `connect()` - private var session: URLSession? = nil - - /// The ongoing task. Assigned during `connect()` - private var task: URLSessionWebSocketTask? = nil - - /** - Initializes a `Transport` layer built using URLSession's WebSocket - - Example: - - ```swift - let url = URL("wss://example.com/socket") - let transport: Transport = URLSessionTransport(url: url) - ``` - - Using a custom `URLSessionConfiguration` - - ```swift - let url = URL("wss://example.com/socket") - let configuration = URLSessionConfiguration.default - let transport: Transport = URLSessionTransport(url: url, configuration: configuration) - ``` - - - parameter url: URL to connect to - - parameter configuration: Provide your own URLSessionConfiguration. Uses `.default` if none provided - */ - public init(url: URL, configuration: URLSessionConfiguration = .default) { - // URLSession requires that the endpoint be "wss" instead of "https". - let endpoint = url.absoluteString - let wsEndpoint = - endpoint - .replacingOccurrences(of: "http://", with: "ws://") - .replacingOccurrences(of: "https://", with: "wss://") - - // Force unwrapping should be safe here since a valid URL came in and we just - // replaced the protocol. - self.url = URL(string: wsEndpoint)! - self.configuration = configuration - - super.init() - } - - // MARK: - Transport - - public var readyState: PhoenixTransportReadyState = .closed - public var delegate: (any PhoenixTransportDelegate)? = nil - - public func connect(with headers: [String: String]) { - // Set the transport state as connecting - readyState = .connecting - - // Create the session and websocket task - session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil) - var request = URLRequest(url: url) - - for (key, value) in headers { - guard let value = value as? String else { continue } - request.addValue(value, forHTTPHeaderField: key) - } - - task = session?.webSocketTask(with: request) - - // Start the task - task?.resume() - } - - open func disconnect(code: Int, reason: String?) { - /* - TODO: - 1. Provide a "strict" mode that fails if an invalid close code is given - 2. If strict mode is disabled, default to CloseCode.invalid - 3. Provide default .normalClosure function - */ - guard let closeCode = URLSessionWebSocketTask.CloseCode(rawValue: code) else { - fatalError("Could not create a CloseCode with invalid code: [\(code)].") - } - - readyState = .closing - task?.cancel(with: closeCode, reason: reason?.data(using: .utf8)) - session?.finishTasksAndInvalidate() - } - - open func send(data: Data) { - Task { - try? await task?.send(.string(String(data: data, encoding: .utf8)!)) - } - } - - // MARK: - URLSessionWebSocketDelegate - - open func urlSession( - _: URLSession, - webSocketTask: URLSessionWebSocketTask, - didOpenWithProtocol _: String? - ) { - // The Websocket is connected. Set Transport state to open and inform delegate - readyState = .open - delegate?.onOpen(response: webSocketTask.response) - - // Start receiving messages - receive() - } - - open func urlSession( - _: URLSession, - webSocketTask _: URLSessionWebSocketTask, - didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, - reason: Data? - ) { - // A close frame was received from the server. - readyState = .closed - delegate?.onClose( - code: closeCode.rawValue, reason: reason.flatMap { String(data: $0, encoding: .utf8) } - ) - } - - open func urlSession( - _: URLSession, - task: URLSessionTask, - didCompleteWithError error: (any Error)? - ) { - // The task has terminated. Inform the delegate that the transport has closed abnormally - // if this was caused by an error. - guard let err = error else { return } - - abnormalErrorReceived(err, response: task.response) - } - - // MARK: - Private - - private func receive() { - Task { - do { - let result = try await task?.receive() - switch result { - case .data: - print("Data received. This method is unsupported by the Client") - case let .string(text): - self.delegate?.onMessage(message: text) - default: - fatalError("Unknown result was received. [\(String(describing: result))]") - } - - // Since `.receive()` is only good for a single message, it must - // be called again after a message is received in order to - // received the next message. - self.receive() - } catch { - print("Error when receiving \(error)") - self.abnormalErrorReceived(error, response: nil) - } - } - } - - private func abnormalErrorReceived(_ error: any Error, response: URLResponse?) { - // Set the state of the Transport to closed - readyState = .closed - - // Inform the Transport's delegate that an error occurred. - delegate?.onError(error: error, response: response) - - // An abnormal error is results in an abnormal closure, such as internet getting dropped - // so inform the delegate that the Transport has closed abnormally. This will kick off - // the reconnect logic. - delegate?.onClose( - code: RealtimeClient.CloseCode.abnormal.rawValue, reason: error.localizedDescription - ) - } -} diff --git a/Sources/Realtime/Deprecated/Presence.swift b/Sources/Realtime/Deprecated/Presence.swift deleted file mode 100644 index 2370697f7..000000000 --- a/Sources/Realtime/Deprecated/Presence.swift +++ /dev/null @@ -1,417 +0,0 @@ -// Copyright (c) 2021 David Stump -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -// THE SOFTWARE. - -import Foundation - -/// The Presence object provides features for syncing presence information from -/// the server with the client and handling presences joining and leaving. -/// -/// ## Syncing state from the server -/// -/// To sync presence state from the server, first instantiate an object and pass -/// your channel in to track lifecycle events: -/// -/// let channel = socket.channel("some:topic") -/// let presence = Presence(channel) -/// -/// If you have custom syncing state events, you can configure the `Presence` -/// object to use those instead. -/// -/// let options = Options(events: [.state: "my_state", .diff: "my_diff"]) -/// let presence = Presence(channel, opts: options) -/// -/// Next, use the presence.onSync callback to react to state changes from the -/// server. For example, to render the list of users every time the list -/// changes, you could write: -/// -/// presence.onSync { renderUsers(presence.list()) } -/// -/// ## Listing Presences -/// -/// presence.list is used to return a list of presence information based on the -/// local state of metadata. By default, all presence metadata is returned, but -/// a listBy function can be supplied to allow the client to select which -/// metadata to use for a given presence. For example, you may have a user -/// online from different devices with a metadata status of "online", but they -/// have set themselves to "away" on another device. In this case, the app may -/// choose to use the "away" status for what appears on the UI. The example -/// below defines a listBy function which prioritizes the first metadata which -/// was registered for each user. This could be the first tab they opened, or -/// the first device they came online from: -/// -/// let listBy: (String, Presence.Map) -> Presence.Meta = { id, pres in -/// let first = pres["metas"]!.first! -/// first["count"] = pres["metas"]!.count -/// first["id"] = id -/// return first -/// } -/// let onlineUsers = presence.list(by: listBy) -/// -/// (NOTE: The underlying behavior is a `map` on the `presence.state`. You are -/// mapping the `state` dictionary into whatever datastructure suites your needs) -/// -/// ## Handling individual presence join and leave events -/// -/// The presence.onJoin and presence.onLeave callbacks can be used to react to -/// individual presences joining and leaving the app. For example: -/// -/// let presence = Presence(channel) -/// presence.onJoin { [weak self] (key, current, newPres) in -/// if let cur = current { -/// print("user additional presence", cur) -/// } else { -/// print("user entered for the first time", newPres) -/// } -/// } -/// -/// presence.onLeave { [weak self] (key, current, leftPres) in -/// if current["metas"]?.isEmpty == true { -/// print("user has left from all devices", leftPres) -/// } else { -/// print("user left from a device", current) -/// } -/// } -/// -/// presence.onSync { renderUsers(presence.list()) } -@available( - *, - deprecated, - renamed: "PresenceV2", - message: "Presence class is deprecated in favor of PresenceV2. See migration guide: https://github.com/supabase-community/supabase-swift/blob/main/docs/migrations/RealtimeV2%20Migration%20Guide.md" -) -public final class Presence { - // ---------------------------------------------------------------------- - - // MARK: - Enums and Structs - - // ---------------------------------------------------------------------- - /// Custom options that can be provided when creating Presence - /// - /// ### Example: - /// - /// let options = Options(events: [.state: "my_state", .diff: "my_diff"]) - /// let presence = Presence(channel, opts: options) - public struct Options { - let events: [Events: String] - - /// Default set of Options used when creating Presence. Uses the - /// phoenix events "presence_state" and "presence_diff" - public static let defaults = Options(events: [ - .state: "presence_state", - .diff: "presence_diff", - ]) - - public init(events: [Events: String]) { - self.events = events - } - } - - /// Presense Events - public enum Events: String { - case state - case diff - } - - // ---------------------------------------------------------------------- - - // MARK: - Typaliases - - // ---------------------------------------------------------------------- - /// Meta details of a Presence. Just a dictionary of properties - public typealias Meta = [String: Any] - - /// A mapping of a String to an array of Metas. e.g. {"metas": [{id: 1}]} - public typealias Map = [String: [Meta]] - - /// A mapping of a Presence state to a mapping of Metas - public typealias State = [String: Map] - - // Diff has keys "joins" and "leaves", pointing to a Presence.State each - // containing the users that joined and left. - public typealias Diff = [String: State] - - /// Closure signature of OnJoin callbacks - public typealias OnJoin = (_ key: String, _ current: Map?, _ new: Map) -> Void - - /// Closure signature for OnLeave callbacks - public typealias OnLeave = (_ key: String, _ current: Map, _ left: Map) -> Void - - //// Closure signature for OnSync callbacks - public typealias OnSync = () -> Void - - /// Collection of callbacks with default values - struct Caller { - var onJoin: OnJoin = { _, _, _ in } - var onLeave: OnLeave = { _, _, _ in } - var onSync: OnSync = {} - } - - // ---------------------------------------------------------------------- - - // MARK: - Properties - - // ---------------------------------------------------------------------- - /// The channel the Presence belongs to - weak var channel: RealtimeChannel? - - /// Caller to callback hooks - var caller: Caller - - /// The state of the Presence - public private(set) var state: State - - /// Pending `join` and `leave` diffs that need to be synced - public private(set) var pendingDiffs: [Diff] - - /// The channel's joinRef, set when state events occur - public private(set) var joinRef: String? - - public var isPendingSyncState: Bool { - guard let safeJoinRef = joinRef else { return true } - return safeJoinRef != channel?.joinRef - } - - /// Callback to be informed of joins - public var onJoin: OnJoin { - get { caller.onJoin } - set { caller.onJoin = newValue } - } - - /// Set the OnJoin callback - public func onJoin(_ callback: @escaping OnJoin) { - onJoin = callback - } - - /// Callback to be informed of leaves - public var onLeave: OnLeave { - get { caller.onLeave } - set { caller.onLeave = newValue } - } - - /// Set the OnLeave callback - public func onLeave(_ callback: @escaping OnLeave) { - onLeave = callback - } - - /// Callback to be informed of synces - public var onSync: OnSync { - get { caller.onSync } - set { caller.onSync = newValue } - } - - /// Set the OnSync callback - public func onSync(_ callback: @escaping OnSync) { - onSync = callback - } - - public init(channel: RealtimeChannel, opts: Options = Options.defaults) { - state = [:] - pendingDiffs = [] - self.channel = channel - joinRef = nil - caller = Caller() - - guard // Do not subscribe to events if they were not provided - let stateEvent = opts.events[.state], - let diffEvent = opts.events[.diff] - else { return } - - self.channel?.delegateOn(stateEvent, filter: ChannelFilter(), to: self) { (self, message) in - guard let newState = message.rawPayload as? State else { return } - - self.joinRef = self.channel?.joinRef - self.state = Presence.syncState( - self.state, - newState: newState, - onJoin: self.caller.onJoin, - onLeave: self.caller.onLeave - ) - - for diff in self.pendingDiffs { - self.state = Presence.syncDiff( - self.state, - diff: diff, - onJoin: self.caller.onJoin, - onLeave: self.caller.onLeave - ) - } - - self.pendingDiffs = [] - self.caller.onSync() - } - - self.channel?.delegateOn(diffEvent, filter: ChannelFilter(), to: self) { (self, message) in - guard let diff = message.rawPayload as? Diff else { return } - if self.isPendingSyncState { - self.pendingDiffs.append(diff) - } else { - self.state = Presence.syncDiff( - self.state, - diff: diff, - onJoin: self.caller.onJoin, - onLeave: self.caller.onLeave - ) - self.caller.onSync() - } - } - } - - /// Returns the array of presences, with deault selected metadata. - public func list() -> [Map] { - list(by: { _, pres in pres }) - } - - /// Returns the array of presences, with selected metadata - public func list(by transformer: (String, Map) -> T) -> [T] { - Presence.listBy(state, transformer: transformer) - } - - /// Filter the Presence state with a given function - public func filter(by filter: ((String, Map) -> Bool)?) -> State { - Presence.filter(state, by: filter) - } - - // ---------------------------------------------------------------------- - - // MARK: - Static - - // ---------------------------------------------------------------------- - - // Used to sync the list of presences on the server - // with the client's state. An optional `onJoin` and `onLeave` callback can - // be provided to react to changes in the client's local presences across - // disconnects and reconnects with the server. - // - // - returns: Presence.State - @discardableResult - public static func syncState( - _ currentState: State, - newState: State, - onJoin: OnJoin = { _, _, _ in }, - onLeave: OnLeave = { _, _, _ in } - ) -> State { - let state = currentState - var leaves: Presence.State = [:] - var joins: Presence.State = [:] - - for (key, presence) in state { - if newState[key] == nil { - leaves[key] = presence - } - } - - for (key, newPresence) in newState { - if let currentPresence = state[key] { - let newRefs = newPresence["metas"]!.map { $0["phx_ref"] as! String } - let curRefs = currentPresence["metas"]!.map { $0["phx_ref"] as! String } - - let joinedMetas = newPresence["metas"]!.filter { (meta: Meta) -> Bool in - !curRefs.contains { $0 == meta["phx_ref"] as! String } - } - let leftMetas = currentPresence["metas"]!.filter { (meta: Meta) -> Bool in - !newRefs.contains { $0 == meta["phx_ref"] as! String } - } - - if joinedMetas.count > 0 { - joins[key] = newPresence - joins[key]!["metas"] = joinedMetas - } - - if leftMetas.count > 0 { - leaves[key] = currentPresence - leaves[key]!["metas"] = leftMetas - } - } else { - joins[key] = newPresence - } - } - - return Presence.syncDiff( - state, - diff: ["joins": joins, "leaves": leaves], - onJoin: onJoin, - onLeave: onLeave - ) - } - - // Used to sync a diff of presence join and leave - // events from the server, as they happen. Like `syncState`, `syncDiff` - // accepts optional `onJoin` and `onLeave` callbacks to react to a user - // joining or leaving from a device. - // - // - returns: Presence.State - @discardableResult - public static func syncDiff( - _ currentState: State, - diff: Diff, - onJoin: OnJoin = { _, _, _ in }, - onLeave: OnLeave = { _, _, _ in } - ) -> State { - var state = currentState - diff["joins"]?.forEach { key, newPresence in - let currentPresence = state[key] - state[key] = newPresence - - if let curPresence = currentPresence { - let joinedRefs = state[key]!["metas"]!.map { $0["phx_ref"] as! String } - let curMetas = curPresence["metas"]!.filter { (meta: Meta) -> Bool in - !joinedRefs.contains { $0 == meta["phx_ref"] as! String } - } - state[key]!["metas"]!.insert(contentsOf: curMetas, at: 0) - } - - onJoin(key, currentPresence, newPresence) - } - - diff["leaves"]?.forEach { key, leftPresence in - guard var curPresence = state[key] else { return } - let refsToRemove = leftPresence["metas"]!.map { $0["phx_ref"] as! String } - let keepMetas = curPresence["metas"]!.filter { (meta: Meta) -> Bool in - !refsToRemove.contains { $0 == meta["phx_ref"] as! String } - } - - curPresence["metas"] = keepMetas - onLeave(key, curPresence, leftPresence) - - if keepMetas.count > 0 { - state[key]!["metas"] = keepMetas - } else { - state.removeValue(forKey: key) - } - } - - return state - } - - public static func filter( - _ presences: State, - by filter: ((String, Map) -> Bool)? - ) -> State { - let safeFilter = filter ?? { _, _ in true } - return presences.filter(safeFilter) - } - - public static func listBy( - _ presences: State, - transformer: (String, Map) -> T - ) -> [T] { - presences.map(transformer) - } -} diff --git a/Sources/Realtime/Deprecated/Push.swift b/Sources/Realtime/Deprecated/Push.swift deleted file mode 100644 index 7f681b6da..000000000 --- a/Sources/Realtime/Deprecated/Push.swift +++ /dev/null @@ -1,265 +0,0 @@ -// Copyright (c) 2021 David Stump -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -// THE SOFTWARE. - -import Foundation - -/// Represnts pushing data to a `Channel` through the `Socket` -public class Push { - /// The channel sending the Push - public weak var channel: RealtimeChannel? - - /// The event, for example `phx_join` - public let event: String - - /// The payload, for example ["user_id": "abc123"] - public var payload: Payload - - /// The push timeout. Default is 10.0 seconds - public var timeout: TimeInterval - - /// The server's response to the Push - var receivedMessage: RealtimeMessage? - - /// Timer which triggers a timeout event - var timeoutTimer: TimerQueue - - /// WorkItem to be performed when the timeout timer fires - var timeoutWorkItem: DispatchWorkItem? - - /// Hooks into a Push. Where .receive("ok", callback(Payload)) are stored - var receiveHooks: [PushStatus: [Delegated]] - - /// True if the Push has been sent - var sent: Bool - - /// The reference ID of the Push - var ref: String? - - /// The event that is associated with the reference ID of the Push - var refEvent: String? - - /// Initializes a Push - /// - /// - parameter channel: The Channel - /// - parameter event: The event, for example ChannelEvent.join - /// - parameter payload: Optional. The Payload to send, e.g. ["user_id": "abc123"] - /// - parameter timeout: Optional. The push timeout. Default is 10.0s - init( - channel: RealtimeChannel, - event: String, - payload: Payload = [:], - timeout: TimeInterval = Defaults.timeoutInterval - ) { - self.channel = channel - self.event = event - self.payload = payload - self.timeout = timeout - receivedMessage = nil - timeoutTimer = TimerQueue.main - receiveHooks = [:] - sent = false - ref = nil - } - - /// Resets and sends the Push - /// - parameter timeout: Optional. The push timeout. Default is 10.0s - public func resend(_ timeout: TimeInterval = Defaults.timeoutInterval) { - self.timeout = timeout - reset() - send() - } - - /// Sends the Push. If it has already timed out, then the call will - /// be ignored and return early. Use `resend` in this case. - public func send() { - guard !hasReceived(status: .timeout) else { return } - - startTimeout() - sent = true - channel?.socket?.push( - topic: channel?.topic ?? "", - event: event, - payload: payload, - ref: ref, - joinRef: channel?.joinRef - ) - } - - /// Receive a specific event when sending an Outbound message. Subscribing - /// to status events with this method does not guarantees no retain cycles. - /// You should pass `weak self` in the capture list of the callback. You - /// can call `.delegateReceive(status:, to:, callback:) and the library will - /// handle it for you. - /// - /// Example: - /// - /// channel - /// .send(event:"custom", payload: ["body": "example"]) - /// .receive("error") { [weak self] payload in - /// print("Error: ", payload) - /// } - /// - /// - parameter status: Status to receive - /// - parameter callback: Callback to fire when the status is recevied - @discardableResult - public func receive( - _ status: PushStatus, - callback: @escaping ((RealtimeMessage) -> Void) - ) -> Push { - var delegated = Delegated() - delegated.manuallyDelegate(with: callback) - - return receive(status, delegated: delegated) - } - - /// Receive a specific event when sending an Outbound message. Automatically - /// prevents retain cycles. See `manualReceive(status:, callback:)` if you - /// want to handle this yourself. - /// - /// Example: - /// - /// channel - /// .send(event:"custom", payload: ["body": "example"]) - /// .delegateReceive("error", to: self) { payload in - /// print("Error: ", payload) - /// } - /// - /// - parameter status: Status to receive - /// - parameter owner: The class that is calling .receive. Usually `self` - /// - parameter callback: Callback to fire when the status is recevied - @discardableResult - public func delegateReceive( - _ status: PushStatus, - to owner: Target, - callback: @escaping ((Target, RealtimeMessage) -> Void) - ) -> Push { - var delegated = Delegated() - delegated.delegate(to: owner, with: callback) - - return receive(status, delegated: delegated) - } - - /// Shared behavior between `receive` calls - @discardableResult - func receive(_ status: PushStatus, delegated: Delegated) -> Push { - // If the message has already been received, pass it to the callback immediately - if hasReceived(status: status), let receivedMessage { - delegated.call(receivedMessage) - } - - if receiveHooks[status] == nil { - /// Create a new array of hooks if no previous hook is associated with status - receiveHooks[status] = [delegated] - } else { - /// A previous hook for this status already exists. Just append the new hook - receiveHooks[status]?.append(delegated) - } - - return self - } - - /// Resets the Push as it was after it was first tnitialized. - func reset() { - cancelRefEvent() - ref = nil - refEvent = nil - receivedMessage = nil - sent = false - } - - /// Finds the receiveHook which needs to be informed of a status response - /// - /// - parameter status: Status which was received, e.g. "ok", "error", "timeout" - /// - parameter response: Response that was received - private func matchReceive(_ status: PushStatus, message: RealtimeMessage) { - receiveHooks[status]?.forEach { $0.call(message) } - } - - /// Reverses the result on channel.on(ChannelEvent, callback) that spawned the Push - private func cancelRefEvent() { - guard let refEvent else { return } - channel?.off(refEvent) - } - - /// Cancel any ongoing Timeout Timer - func cancelTimeout() { - timeoutWorkItem?.cancel() - timeoutWorkItem = nil - } - - /// Starts the Timer which will trigger a timeout after a specific _timeout_ - /// time, in milliseconds, is reached. - func startTimeout() { - // Cancel any existing timeout before starting a new one - if let safeWorkItem = timeoutWorkItem, !safeWorkItem.isCancelled { - cancelTimeout() - } - - guard - let channel, - let socket = channel.socket - else { return } - - let ref = socket.makeRef() - let refEvent = channel.replyEventName(ref) - - self.ref = ref - self.refEvent = refEvent - - /// If a response is received before the Timer triggers, cancel timer - /// and match the received event to it's corresponding hook - channel.delegateOn(refEvent, filter: ChannelFilter(), to: self) { (self, message) in - self.cancelRefEvent() - self.cancelTimeout() - self.receivedMessage = message - - /// Check if there is event a status available - guard let status = message.status else { return } - self.matchReceive(status, message: message) - } - - /// Setup and start the Timeout timer. - let workItem = DispatchWorkItem { - self.trigger(.timeout, payload: [:]) - } - - timeoutWorkItem = workItem - timeoutTimer.queue(timeInterval: timeout, execute: workItem) - } - - /// Checks if a status has already been received by the Push. - /// - /// - parameter status: Status to check - /// - return: True if given status has been received by the Push. - func hasReceived(status: PushStatus) -> Bool { - receivedMessage?.status == status - } - - /// Triggers an event to be sent though the Channel - func trigger(_ status: PushStatus, payload: Payload) { - /// If there is no ref event, then there is nothing to trigger on the channel - guard let refEvent else { return } - - var mutPayload = payload - mutPayload["status"] = status.rawValue - - channel?.trigger(event: refEvent, payload: mutPayload) - } -} diff --git a/Sources/Realtime/Deprecated/RealtimeChannel.swift b/Sources/Realtime/Deprecated/RealtimeChannel.swift deleted file mode 100644 index 22169bc19..000000000 --- a/Sources/Realtime/Deprecated/RealtimeChannel.swift +++ /dev/null @@ -1,1037 +0,0 @@ -// Copyright (c) 2021 David Stump -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -// THE SOFTWARE. - -import ConcurrencyExtras -import Foundation -import Swift -import HTTPTypes - -/// Container class of bindings to the channel -struct Binding { - let type: String - let filter: [String: String] - - // The callback to be triggered - let callback: Delegated - - let id: String? -} - -public struct ChannelFilter { - public var event: String? - public var schema: String? - public let table: String? - public let filter: String? - - public init( - event: String? = nil, schema: String? = nil, table: String? = nil, filter: String? = nil - ) { - self.event = event - self.schema = schema - self.table = table - self.filter = filter - } - - var asDictionary: [String: String] { - [ - "event": event, - "schema": schema, - "table": table, - "filter": filter, - ].compactMapValues { $0 } - } -} - -public enum ChannelResponse { - case ok, timedOut, error -} - -public enum RealtimeListenTypes: String { - case postgresChanges = "postgres_changes" - case broadcast - case presence -} - -/// Represents the broadcast and presence options for a channel. -public struct RealtimeChannelOptions { - /// Used to track presence payload across clients. Must be unique per client. If `nil`, the server - /// will generate one. - var presenceKey: String? - /// Enables the client to receive their own`broadcast` messages - var broadcastSelf: Bool - /// Instructs the server to acknowledge the client's `broadcast` messages - var broadcastAcknowledge: Bool - - public init( - presenceKey: String? = nil, - broadcastSelf: Bool = false, - broadcastAcknowledge: Bool = false - ) { - self.presenceKey = presenceKey - self.broadcastSelf = broadcastSelf - self.broadcastAcknowledge = broadcastAcknowledge - } - - /// Parameters used to configure the channel - var params: [String: [String: Any]] { - [ - "config": [ - "presence": [ - "key": presenceKey ?? "", - ], - "broadcast": [ - "ack": broadcastAcknowledge, - "self": broadcastSelf, - ], - ], - ] - } -} - -public enum RealtimeSubscribeStates { - case subscribed - case timedOut - case closed - case channelError -} - -/// -/// Represents a RealtimeChannel which is bound to a topic -/// -/// A RealtimeChannel can bind to multiple events on a given topic and -/// be informed when those events occur within a topic. -/// -/// ### Example: -/// -/// let channel = socket.channel("room:123", params: ["token": "Room Token"]) -/// channel.on("new_msg") { payload in print("Got message", payload") } -/// channel.push("new_msg, payload: ["body": "This is a message"]) -/// .receive("ok") { payload in print("Sent message", payload) } -/// .receive("error") { payload in print("Send failed", payload) } -/// .receive("timeout") { payload in print("Networking issue...", payload) } -/// -/// channel.join() -/// .receive("ok") { payload in print("RealtimeChannel Joined", payload) } -/// .receive("error") { payload in print("Failed ot join", payload) } -/// .receive("timeout") { payload in print("Networking issue...", payload) } -/// -@available( - *, - deprecated, - message: "Use new RealtimeChannelV2 class instead. See migration guide: https://github.com/supabase-community/supabase-swift/blob/main/docs/migrations/RealtimeV2%20Migration%20Guide.md" -) -public class RealtimeChannel { - /// The topic of the RealtimeChannel. e.g. "rooms:friends" - public let topic: String - - /// The params sent when joining the channel - public var params: Payload { - didSet { joinPush.payload = params } - } - - public private(set) lazy var presence = Presence(channel: self) - - /// The Socket that the channel belongs to - weak var socket: RealtimeClient? - - var subTopic: String - - /// Current state of the RealtimeChannel - var state: ChannelState - - /// Collection of event bindings - let bindings: LockIsolated<[String: [Binding]]> - - /// Timeout when attempting to join a RealtimeChannel - var timeout: TimeInterval - - /// Set to true once the channel calls .join() - var joinedOnce: Bool - - /// Push to send when the channel calls .join() - var joinPush: Push! - - /// Buffer of Pushes that will be sent once the RealtimeChannel's socket connects - var pushBuffer: [Push] - - /// Timer to attempt to rejoin - var rejoinTimer: TimeoutTimer - - /// Refs of stateChange hooks - var stateChangeRefs: [String] - - /// Initialize a RealtimeChannel - /// - /// - parameter topic: Topic of the RealtimeChannel - /// - parameter params: Optional. Parameters to send when joining. - /// - parameter socket: Socket that the channel is a part of - init(topic: String, params: [String: Any] = [:], socket: RealtimeClient) { - state = ChannelState.closed - self.topic = topic - subTopic = topic.replacingOccurrences(of: "realtime:", with: "") - self.params = params - self.socket = socket - bindings = LockIsolated([:]) - timeout = socket.timeout - joinedOnce = false - pushBuffer = [] - stateChangeRefs = [] - rejoinTimer = TimeoutTimer() - - // Setup Timer delgation - rejoinTimer.callback - .delegate(to: self) { (self) in - if self.socket?.isConnected == true { self.rejoin() } - } - - rejoinTimer.timerCalculation - .delegate(to: self) { (self, tries) -> TimeInterval in - self.socket?.rejoinAfter(tries) ?? 5.0 - } - - // Respond to socket events - let onErrorRef = self.socket?.delegateOnError( - to: self, - callback: { (self, _) in - self.rejoinTimer.reset() - } - ) - if let ref = onErrorRef { stateChangeRefs.append(ref) } - - let onOpenRef = self.socket?.delegateOnOpen( - to: self, - callback: { (self) in - self.rejoinTimer.reset() - if self.isErrored { self.rejoin() } - } - ) - if let ref = onOpenRef { stateChangeRefs.append(ref) } - - // Setup Push Event to be sent when joining - joinPush = Push( - channel: self, - event: ChannelEvent.join, - payload: self.params, - timeout: timeout - ) - - /// Handle when a response is received after join() - joinPush.delegateReceive(.ok, to: self) { (self, _) in - // Mark the RealtimeChannel as joined - self.state = ChannelState.joined - - // Reset the timer, preventing it from attempting to join again - self.rejoinTimer.reset() - - // Send and buffered messages and clear the buffer - self.pushBuffer.forEach { $0.send() } - self.pushBuffer = [] - } - - // Perform if RealtimeChannel errors while attempting to joi - joinPush.delegateReceive(.error, to: self) { (self, _) in - self.state = .errored - if self.socket?.isConnected == true { self.rejoinTimer.scheduleTimeout() } - } - - // Handle when the join push times out when sending after join() - joinPush.delegateReceive(.timeout, to: self) { (self, _) in - // log that the channel timed out - self.socket?.logItems( - "channel", "timeout \(self.topic) \(self.joinRef ?? "") after \(self.timeout)s" - ) - - // Send a Push to the server to leave the channel - let leavePush = Push( - channel: self, - event: ChannelEvent.leave, - timeout: self.timeout - ) - leavePush.send() - - // Mark the RealtimeChannel as in an error and attempt to rejoin if socket is connected - self.state = ChannelState.errored - self.joinPush.reset() - - if self.socket?.isConnected == true { self.rejoinTimer.scheduleTimeout() } - } - - /// Perfom when the RealtimeChannel has been closed - delegateOnClose(to: self) { (self, _) in - // Reset any timer that may be on-going - self.rejoinTimer.reset() - - // Log that the channel was left - self.socket?.logItems( - "channel", "close topic: \(self.topic) joinRef: \(self.joinRef ?? "nil")" - ) - - // Mark the channel as closed and remove it from the socket - self.state = ChannelState.closed - self.socket?.remove(self) - } - - /// Perfom when the RealtimeChannel errors - delegateOnError(to: self) { (self, message) in - // Log that the channel received an error - self.socket?.logItems( - "channel", "error topic: \(self.topic) joinRef: \(self.joinRef ?? "nil") mesage: \(message)" - ) - - // If error was received while joining, then reset the Push - if self.isJoining { - // Make sure that the "phx_join" isn't buffered to send once the socket - // reconnects. The channel will send a new join event when the socket connects. - if let safeJoinRef = self.joinRef { - self.socket?.removeFromSendBuffer(ref: safeJoinRef) - } - - // Reset the push to be used again later - self.joinPush.reset() - } - - // Mark the channel as errored and attempt to rejoin if socket is currently connected - self.state = ChannelState.errored - if self.socket?.isConnected == true { self.rejoinTimer.scheduleTimeout() } - } - - // Perform when the join reply is received - delegateOn(ChannelEvent.reply, filter: ChannelFilter(), to: self) { (self, message) in - // Trigger bindings - self.trigger( - event: self.replyEventName(message.ref), - payload: message.rawPayload, - ref: message.ref, - joinRef: message.joinRef - ) - } - } - - deinit { - rejoinTimer.reset() - } - - /// Overridable message hook. Receives all events for specialized message - /// handling before dispatching to the channel callbacks. - /// - /// - parameter msg: The Message received by the client from the server - /// - return: Must return the message, modified or unmodified - public var onMessage: (_ message: RealtimeMessage) -> RealtimeMessage = { message in - message - } - - /// Joins the channel - /// - /// - parameter timeout: Optional. Defaults to RealtimeChannel's timeout - /// - return: Push event - @discardableResult - public func subscribe( - timeout: TimeInterval? = nil, - callback: ((RealtimeSubscribeStates, (any Error)?) -> Void)? = nil - ) -> RealtimeChannel { - if socket?.isConnected == false { - socket?.connect() - } - - guard !joinedOnce else { - fatalError( - "tried to join multiple times. 'join' " - + "can only be called a single time per channel instance" - ) - } - - onError { message in - let values = message.payload.values.map { "\($0) " } - let error = RealtimeError(values.isEmpty ? "error" : values.joined(separator: ", ")) - callback?(.channelError, error) - } - - onClose { _ in - callback?(.closed, nil) - } - - // Join the RealtimeChannel - if let safeTimeout = timeout { - self.timeout = safeTimeout - } - - let broadcast = params["config", as: [String: Any].self]?["broadcast"] - let presence = params["config", as: [String: Any].self]?["presence"] - - var accessTokenPayload: Payload = [:] - var config: Payload = [ - "postgres_changes": bindings.value["postgres_changes"]?.map(\.filter) ?? [], - ] - - config["broadcast"] = broadcast - config["presence"] = presence - - if let accessToken = socket?.accessToken { - accessTokenPayload["access_token"] = accessToken - } - - params["config"] = config - - joinedOnce = true - rejoin() - - joinPush - .delegateReceive(.ok, to: self) { (self, message) in - if self.socket?.accessToken != nil { - self.socket?.setAuth(self.socket?.accessToken) - } - - guard let serverPostgresFilters = message.payload["postgres_changes"] as? [[String: Any]] - else { - callback?(.subscribed, nil) - return - } - - let clientPostgresBindings = self.bindings.value["postgres_changes"] ?? [] - let bindingsCount = clientPostgresBindings.count - var newPostgresBindings: [Binding] = [] - - for i in 0 ..< bindingsCount { - let clientPostgresBinding = clientPostgresBindings[i] - - let event = clientPostgresBinding.filter["event"] - let schema = clientPostgresBinding.filter["schema"] - let table = clientPostgresBinding.filter["table"] - let filter = clientPostgresBinding.filter["filter"] - - let serverPostgresFilter = serverPostgresFilters[i] - - if serverPostgresFilter["event", as: String.self] == event, - serverPostgresFilter["schema", as: String.self] == schema, - serverPostgresFilter["table", as: String.self] == table, - serverPostgresFilter["filter", as: String.self] == filter - { - newPostgresBindings.append( - Binding( - type: clientPostgresBinding.type, - filter: clientPostgresBinding.filter, - callback: clientPostgresBinding.callback, - id: serverPostgresFilter["id", as: Int.self].flatMap(String.init) - ) - ) - } else { - self.unsubscribe() - callback?( - .channelError, - RealtimeError("Mismatch between client and server bindings for postgres changes.") - ) - return - } - } - - self.bindings.withValue { [newPostgresBindings] in - $0["postgres_changes"] = newPostgresBindings - } - callback?(.subscribed, nil) - } - .delegateReceive(.error, to: self) { _, message in - let values = message.payload.values.map { "\($0) " } - let error = RealtimeError(values.isEmpty ? "error" : values.joined(separator: ", ")) - callback?(.channelError, error) - } - .delegateReceive(.timeout, to: self) { _, _ in - callback?(.timedOut, nil) - } - - return self - } - - public func presenceState() -> Presence.State { - presence.state - } - - public func track(_ payload: Payload, opts: Payload = [:]) async -> ChannelResponse { - await send( - type: .presence, - payload: [ - "event": "track", - "payload": payload, - ], - opts: opts - ) - } - - public func untrack(opts: Payload = [:]) async -> ChannelResponse { - await send( - type: .presence, - payload: ["event": "untrack"], - opts: opts - ) - } - - /// Hook into when the RealtimeChannel is closed. Does not handle retain cycles. - /// Use `delegateOnClose(to:)` for automatic handling of retain cycles. - /// - /// Example: - /// - /// let channel = socket.channel("topic") - /// channel.onClose() { [weak self] message in - /// self?.print("RealtimeChannel \(message.topic) has closed" - /// } - /// - /// - parameter handler: Called when the RealtimeChannel closes - /// - return: Ref counter of the subscription. See `func off()` - @discardableResult - public func onClose(_ handler: @escaping ((RealtimeMessage) -> Void)) -> RealtimeChannel { - on(ChannelEvent.close, filter: ChannelFilter(), handler: handler) - } - - /// Hook into when the RealtimeChannel is closed. Automatically handles retain - /// cycles. Use `onClose()` to handle yourself. - /// - /// Example: - /// - /// let channel = socket.channel("topic") - /// channel.delegateOnClose(to: self) { (self, message) in - /// self.print("RealtimeChannel \(message.topic) has closed" - /// } - /// - /// - parameter owner: Class registering the callback. Usually `self` - /// - parameter callback: Called when the RealtimeChannel closes - /// - return: Ref counter of the subscription. See `func off()` - @discardableResult - public func delegateOnClose( - to owner: Target, - callback: @escaping ((Target, RealtimeMessage) -> Void) - ) -> RealtimeChannel { - delegateOn( - ChannelEvent.close, filter: ChannelFilter(), to: owner, callback: callback - ) - } - - /// Hook into when the RealtimeChannel receives an Error. Does not handle retain - /// cycles. Use `delegateOnError(to:)` for automatic handling of retain - /// cycles. - /// - /// Example: - /// - /// let channel = socket.channel("topic") - /// channel.onError() { [weak self] (message) in - /// self?.print("RealtimeChannel \(message.topic) has errored" - /// } - /// - /// - parameter handler: Called when the RealtimeChannel closes - /// - return: Ref counter of the subscription. See `func off()` - @discardableResult - public func onError(_ handler: @escaping ((_ message: RealtimeMessage) -> Void)) - -> RealtimeChannel - { - on(ChannelEvent.error, filter: ChannelFilter(), handler: handler) - } - - /// Hook into when the RealtimeChannel receives an Error. Automatically handles - /// retain cycles. Use `onError()` to handle yourself. - /// - /// Example: - /// - /// let channel = socket.channel("topic") - /// channel.delegateOnError(to: self) { (self, message) in - /// self.print("RealtimeChannel \(message.topic) has closed" - /// } - /// - /// - parameter owner: Class registering the callback. Usually `self` - /// - parameter callback: Called when the RealtimeChannel closes - /// - return: Ref counter of the subscription. See `func off()` - @discardableResult - public func delegateOnError( - to owner: Target, - callback: @escaping ((Target, RealtimeMessage) -> Void) - ) -> RealtimeChannel { - delegateOn( - ChannelEvent.error, filter: ChannelFilter(), to: owner, callback: callback - ) - } - - /// Subscribes on channel events. Does not handle retain cycles. Use - /// `delegateOn(_:, to:)` for automatic handling of retain cycles. - /// - /// Subscription returns a ref counter, which can be used later to - /// unsubscribe the exact event listener - /// - /// Example: - /// - /// let channel = socket.channel("topic") - /// let ref1 = channel.on("event") { [weak self] (message) in - /// self?.print("do stuff") - /// } - /// let ref2 = channel.on("event") { [weak self] (message) in - /// self?.print("do other stuff") - /// } - /// channel.off("event", ref1) - /// - /// Since unsubscription of ref1, "do stuff" won't print, but "do other - /// stuff" will keep on printing on the "event" - /// - /// - parameter event: Event to receive - /// - parameter handler: Called with the event's message - /// - return: Ref counter of the subscription. See `func off()` - @discardableResult - public func on( - _ event: String, - filter: ChannelFilter, - handler: @escaping ((RealtimeMessage) -> Void) - ) -> RealtimeChannel { - var delegated = Delegated() - delegated.manuallyDelegate(with: handler) - - return on(event, filter: filter, delegated: delegated) - } - - /// Subscribes on channel events. Automatically handles retain cycles. Use - /// `on()` to handle yourself. - /// - /// Subscription returns a ref counter, which can be used later to - /// unsubscribe the exact event listener - /// - /// Example: - /// - /// let channel = socket.channel("topic") - /// let ref1 = channel.delegateOn("event", to: self) { (self, message) in - /// self?.print("do stuff") - /// } - /// let ref2 = channel.delegateOn("event", to: self) { (self, message) in - /// self?.print("do other stuff") - /// } - /// channel.off("event", ref1) - /// - /// Since unsubscription of ref1, "do stuff" won't print, but "do other - /// stuff" will keep on printing on the "event" - /// - /// - parameter event: Event to receive - /// - parameter owner: Class registering the callback. Usually `self` - /// - parameter callback: Called with the event's message - /// - return: Ref counter of the subscription. See `func off()` - @discardableResult - public func delegateOn( - _ event: String, - filter: ChannelFilter, - to owner: Target, - callback: @escaping ((Target, RealtimeMessage) -> Void) - ) -> RealtimeChannel { - var delegated = Delegated() - delegated.delegate(to: owner, with: callback) - - return on(event, filter: filter, delegated: delegated) - } - - /// Shared method between `on` and `manualOn` - @discardableResult - private func on( - _ type: String, filter: ChannelFilter, delegated: Delegated - ) -> RealtimeChannel { - bindings.withValue { - $0[type.lowercased(), default: []].append( - Binding(type: type.lowercased(), filter: filter.asDictionary, callback: delegated, id: nil) - ) - } - - return self - } - - /// Unsubscribes from a channel event. If a `ref` is given, only the exact - /// listener will be removed. Else all listeners for the `event` will be - /// removed. - /// - /// Example: - /// - /// let channel = socket.channel("topic") - /// let ref1 = channel.on("event") { _ in print("ref1 event" } - /// let ref2 = channel.on("event") { _ in print("ref2 event" } - /// let ref3 = channel.on("other_event") { _ in print("ref3 other" } - /// let ref4 = channel.on("other_event") { _ in print("ref4 other" } - /// channel.off("event", ref1) - /// channel.off("other_event") - /// - /// After this, only "ref2 event" will be printed if the channel receives - /// "event" and nothing is printed if the channel receives "other_event". - /// - /// - parameter event: Event to unsubscribe from - /// - parameter ref: Ref counter returned when subscribing. Can be omitted - public func off(_ type: String, filter: [String: String] = [:]) { - bindings.withValue { - $0[type.lowercased()] = $0[type.lowercased(), default: []].filter { bind in - !(bind.type.lowercased() == type.lowercased() && bind.filter == filter) - } - } - } - - /// Push a payload to the RealtimeChannel - /// - /// Example: - /// - /// channel - /// .push("event", payload: ["message": "hello") - /// .receive("ok") { _ in { print("message sent") } - /// - /// - parameter event: Event to push - /// - parameter payload: Payload to push - /// - parameter timeout: Optional timeout - @discardableResult - public func push( - _ event: String, - payload: Payload, - timeout: TimeInterval = Defaults.timeoutInterval - ) -> Push { - guard joinedOnce else { - fatalError( - "Tried to push \(event) to \(topic) before joining. Use channel.join() before pushing events" - ) - } - - let pushEvent = Push( - channel: self, - event: event, - payload: payload, - timeout: timeout - ) - if canPush { - pushEvent.send() - } else { - pushEvent.startTimeout() - pushBuffer.append(pushEvent) - } - - return pushEvent - } - - public func send( - type: RealtimeListenTypes, - event: String? = nil, - payload: Payload, - opts: Payload = [:] - ) async -> ChannelResponse { - var payload = payload - payload["type"] = type.rawValue - if let event { - payload["event"] = event - } - - if !canPush, type == .broadcast { - var headers = socket?.headers ?? [:] - headers["Content-Type"] = "application/json" - headers["apikey"] = socket?.accessToken - - let body = [ - "messages": [ - "topic": subTopic, - "payload": payload, - "event": event as Any, - ], - ] - - do { - let request = try HTTPRequest( - url: broadcastEndpointURL, - method: .post, - headers: HTTPFields(headers.compactMapValues { $0 }), - body: JSONSerialization.data(withJSONObject: body) - ) - - let response = try await socket?.http.send(request) - guard let response, 200 ..< 300 ~= response.statusCode else { - return .error - } - return .ok - } catch { - return .error - } - } else { - return await withCheckedContinuation { continuation in - let push = self.push( - type.rawValue, payload: payload, - timeout: (opts["timeout"] as? TimeInterval) ?? self.timeout - ) - - if let type = payload["type"] as? String, type == "broadcast", - let config = self.params["config"] as? [String: Any], - let broadcast = config["broadcast"] as? [String: Any] - { - let ack = broadcast["ack"] as? Bool - if ack == nil || ack == false { - continuation.resume(returning: .ok) - return - } - } - - push - .receive(.ok) { _ in - continuation.resume(returning: .ok) - } - .receive(.timeout) { _ in - continuation.resume(returning: .timedOut) - } - } - } - } - - /// Leaves the channel - /// - /// Unsubscribes from server events, and instructs channel to terminate on - /// server - /// - /// Triggers onClose() hooks - /// - /// To receive leave acknowledgements, use the a `receive` - /// hook to bind to the server ack, ie: - /// - /// Example: - //// - /// channel.leave().receive("ok") { _ in { print("left") } - /// - /// - parameter timeout: Optional timeout - /// - return: Push that can add receive hooks - @discardableResult - public func unsubscribe(timeout: TimeInterval = Defaults.timeoutInterval) -> Push { - // If attempting a rejoin during a leave, then reset, cancelling the rejoin - rejoinTimer.reset() - - // Now set the state to leaving - state = .leaving - - /// Delegated callback for a successful or a failed channel leave - var onCloseDelegate = Delegated() - onCloseDelegate.delegate(to: self) { (self, _) in - self.socket?.logItems("channel", "leave \(self.topic)") - - // Triggers onClose() hooks - self.trigger(event: ChannelEvent.close, payload: ["reason": "leave"]) - } - - // Push event to send to the server - let leavePush = Push( - channel: self, - event: ChannelEvent.leave, - timeout: timeout - ) - - // Perform the same behavior if successfully left the channel - // or if sending the event timed out - leavePush - .receive(.ok, delegated: onCloseDelegate) - .receive(.timeout, delegated: onCloseDelegate) - leavePush.send() - - // If the RealtimeChannel cannot send push events, trigger a success locally - if !canPush { - leavePush.trigger(.ok, payload: [:]) - } - - // Return the push so it can be bound to - return leavePush - } - - /// Overridable message hook. Receives all events for specialized message - /// handling before dispatching to the channel callbacks. - /// - /// - parameter event: The event the message was for - /// - parameter payload: The payload for the message - /// - parameter ref: The reference of the message - /// - return: Must return the payload, modified or unmodified - public func onMessage(callback: @escaping (RealtimeMessage) -> RealtimeMessage) { - onMessage = callback - } - - // ---------------------------------------------------------------------- - - // MARK: - Internal - - // ---------------------------------------------------------------------- - /// Checks if an event received by the Socket belongs to this RealtimeChannel - func isMember(_ message: RealtimeMessage) -> Bool { - // Return false if the message's topic does not match the RealtimeChannel's topic - guard message.topic == topic else { return false } - - guard - let safeJoinRef = message.joinRef, - safeJoinRef != joinRef, - ChannelEvent.isLifecyleEvent(message.event) - else { return true } - - socket?.logItems( - "channel", "dropping outdated message", message.topic, message.event, message.rawPayload, - safeJoinRef - ) - return false - } - - /// Sends the payload to join the RealtimeChannel - func sendJoin(_ timeout: TimeInterval) { - state = ChannelState.joining - joinPush.resend(timeout) - } - - /// Rejoins the channel - func rejoin(_ timeout: TimeInterval? = nil) { - // Do not attempt to rejoin if the channel is in the process of leaving - guard !isLeaving else { return } - - // Leave potentially duplicate channels - socket?.leaveOpenTopic(topic: topic) - - // Send the joinPush - sendJoin(timeout ?? self.timeout) - } - - /// Triggers an event to the correct event bindings created by - /// `channel.on("event")`. - /// - /// - parameter message: Message to pass to the event bindings - func trigger(_ message: RealtimeMessage) { - let typeLower = message.event.lowercased() - - let events = Set([ - ChannelEvent.close, - ChannelEvent.error, - ChannelEvent.leave, - ChannelEvent.join, - ]) - - if message.ref != message.joinRef, events.contains(typeLower) { - return - } - - let handledMessage = message - - let bindings: [Binding] = if ["insert", "update", "delete"].contains(typeLower) { - self.bindings.value["postgres_changes", default: []].filter { bind in - bind.filter["event"] == "*" || bind.filter["event"] == typeLower - } - } else { - self.bindings.value[typeLower, default: []].filter { bind in - if ["broadcast", "presence", "postgres_changes"].contains(typeLower) { - let bindEvent = bind.filter["event"]?.lowercased() - - if let bindId = bind.id.flatMap(Int.init) { - let ids = message.payload["ids", as: [Int].self] ?? [] - return ids.contains(bindId) - && ( - bindEvent == "*" - || bindEvent - == message.payload["data", as: [String: Any].self]?["type", as: String.self]? - .lowercased() - ) - } - - return bindEvent == "*" - || bindEvent == message.payload["event", as: String.self]?.lowercased() - } - - return bind.type.lowercased() == typeLower - } - } - - bindings.forEach { $0.callback.call(handledMessage) } - } - - /// Triggers an event to the correct event bindings created by - //// `channel.on("event")`. - /// - /// - parameter event: Event to trigger - /// - parameter payload: Payload of the event - /// - parameter ref: Ref of the event. Defaults to empty - /// - parameter joinRef: Ref of the join event. Defaults to nil - func trigger( - event: String, - payload: Payload = [:], - ref: String = "", - joinRef: String? = nil - ) { - let message = RealtimeMessage( - ref: ref, - topic: topic, - event: event, - payload: payload, - joinRef: joinRef ?? self.joinRef - ) - trigger(message) - } - - /// - parameter ref: The ref of the event push - /// - return: The event name of the reply - func replyEventName(_ ref: String) -> String { - "chan_reply_\(ref)" - } - - /// The Ref send during the join message. - var joinRef: String? { - joinPush.ref - } - - /// - return: True if the RealtimeChannel can push messages, meaning the socket - /// is connected and the channel is joined - var canPush: Bool { - socket?.isConnected == true && isJoined - } - - var broadcastEndpointURL: URL { - var url = socket?.endPoint ?? "" - url = url.replacingOccurrences(of: "^ws", with: "http", options: .regularExpression, range: nil) - url = url.replacingOccurrences( - of: "(/socket/websocket|/socket|/websocket)/?$", with: "", options: .regularExpression, - range: nil - ) - url = - "\(url.replacingOccurrences(of: "/+$", with: "", options: .regularExpression, range: nil))/api/broadcast" - return URL(string: url)! - } -} - -// ---------------------------------------------------------------------- - -// MARK: - Public API - -// ---------------------------------------------------------------------- -extension RealtimeChannel { - /// - return: True if the RealtimeChannel has been closed - public var isClosed: Bool { - state == .closed - } - - /// - return: True if the RealtimeChannel experienced an error - public var isErrored: Bool { - state == .errored - } - - /// - return: True if the channel has joined - public var isJoined: Bool { - state == .joined - } - - /// - return: True if the channel has requested to join - public var isJoining: Bool { - state == .joining - } - - /// - return: True if the channel has requested to leave - public var isLeaving: Bool { - state == .leaving - } -} - -extension [String: Any] { - subscript(_ key: Key, as _: T.Type) -> T? { - self[key] as? T - } -} diff --git a/Sources/Realtime/Deprecated/RealtimeClient.swift b/Sources/Realtime/Deprecated/RealtimeClient.swift deleted file mode 100644 index d1eabe92f..000000000 --- a/Sources/Realtime/Deprecated/RealtimeClient.swift +++ /dev/null @@ -1,1071 +0,0 @@ -// Copyright (c) 2021 David Stump -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -// THE SOFTWARE. - -import ConcurrencyExtras -import Foundation - -#if canImport(FoundationNetworking) - import FoundationNetworking -#endif - -public enum SocketError: Error { - case abnormalClosureError -} - -/// Alias for a JSON dictionary [String: Any] -public typealias Payload = [String: Any] - -/// Alias for a function returning an optional JSON dictionary (`Payload?`) -public typealias PayloadClosure = () -> Payload? - -/// Struct that gathers callbacks assigned to the Socket -struct StateChangeCallbacks { - var open: LockIsolated<[(ref: String, callback: Delegated)]> = .init([]) - var close: LockIsolated<[(ref: String, callback: Delegated<(Int, String?), Void>)]> = .init([]) - var error: LockIsolated<[(ref: String, callback: Delegated<(any Error, URLResponse?), Void>)]> = - .init([]) - var message: LockIsolated<[(ref: String, callback: Delegated)]> = .init([]) -} - -/// ## Socket Connection -/// A single connection is established to the server and -/// channels are multiplexed over the connection. -/// Connect to the server using the `RealtimeClient` class: -/// -/// ```swift -/// let socket = new RealtimeClient("/socket", paramsClosure: { ["userToken": "123" ] }) -/// socket.connect() -/// ``` -/// -/// The `RealtimeClient` constructor takes the mount point of the socket, -/// the authentication params, as well as options that can be found in -/// the Socket docs, such as configuring the heartbeat. -@available( - *, - deprecated, - message: "Use new RealtimeClientV2 class instead. See migration guide: https://github.com/supabase-community/supabase-swift/blob/main/docs/migrations/RealtimeV2%20Migration%20Guide.md" -) -public class RealtimeClient: PhoenixTransportDelegate { - // ---------------------------------------------------------------------- - - // MARK: - Public Attributes - - // ---------------------------------------------------------------------- - /// The string WebSocket endpoint (ie `"ws://example.com/socket"`, - /// `"wss://example.com"`, etc.) That was passed to the Socket during - /// initialization. The URL endpoint will be modified by the Socket to - /// include `"/websocket"` if missing. - public let endPoint: String - - /// The fully qualified socket URL - public private(set) var endPointUrl: URL - - /// Resolves to return the `paramsClosure` result at the time of calling. - /// If the `Socket` was created with static params, then those will be - /// returned every time. - public var params: Payload? { - paramsClosure?() - } - - /// The optional params closure used to get params when connecting. Must - /// be set when initializing the Socket. - public let paramsClosure: PayloadClosure? - - /// The WebSocket transport. Default behavior is to provide a - /// URLSessionWebsocketTask. See README for alternatives. - private let transport: (URL) -> any PhoenixTransport - - /// Phoenix serializer version, defaults to "2.0.0" - public let vsn: String - - /// Override to provide custom encoding of data before writing to the socket - public var encode: (Any) -> Data = Defaults.encode - - /// Override to provide custom decoding of data read from the socket - public var decode: (Data) -> Any? = Defaults.decode - - /// Timeout to use when opening connections - public var timeout: TimeInterval = Defaults.timeoutInterval - - /// Custom headers to be added to the socket connection request - public var headers: [String: String] = [:] - - /// Interval between sending a heartbeat - public var heartbeatInterval: TimeInterval = Defaults.heartbeatInterval - - /// The maximum amount of time which the system may delay heartbeats in order to optimize power - /// usage - public var heartbeatLeeway: DispatchTimeInterval = Defaults.heartbeatLeeway - - /// Interval between socket reconnect attempts, in seconds - public var reconnectAfter: (Int) -> TimeInterval = Defaults.reconnectSteppedBackOff - - /// Interval between channel rejoin attempts, in seconds - public var rejoinAfter: (Int) -> TimeInterval = Defaults.rejoinSteppedBackOff - - /// The optional function to receive logs - public var logger: ((String) -> Void)? - - /// Disables heartbeats from being sent. Default is false. - public var skipHeartbeat: Bool = false - - /// Enable/Disable SSL certificate validation. Default is false. This - /// must be set before calling `socket.connect()` in order to be applied - public var disableSSLCertValidation: Bool = false - - #if os(Linux) || os(Windows) || os(Android) - #else - /// Configure custom SSL validation logic, eg. SSL pinning. This - /// must be set before calling `socket.connect()` in order to apply. - // public var security: SSLTrustValidator? - - /// Configure the encryption used by your client by setting the - /// allowed cipher suites supported by your server. This must be - /// set before calling `socket.connect()` in order to apply. - public var enabledSSLCipherSuites: [SSLCipherSuite]? - #endif - - // ---------------------------------------------------------------------- - - // MARK: - Private Attributes - - // ---------------------------------------------------------------------- - /// Callbacks for socket state changes - var stateChangeCallbacks: StateChangeCallbacks = .init() - - /// Collection on channels created for the Socket - public internal(set) var channels: [RealtimeChannel] = [] - - /// Buffers messages that need to be sent once the socket has connected. It is an array - /// of tuples, with the ref of the message to send and the callback that will send the message. - var sendBuffer: [(ref: String?, callback: () throws -> Void)] = [] - - /// Ref counter for messages - var ref: UInt64 = .min // 0 (max: 18,446,744,073,709,551,615) - - /// Timer that triggers sending new Heartbeat messages - var heartbeatTimer: HeartbeatTimer? - - /// Ref counter for the last heartbeat that was sent - var pendingHeartbeatRef: String? - - /// Timer to use when attempting to reconnect - var reconnectTimer: TimeoutTimer - - /// Close status - var closeStatus: CloseStatus = .unknown - - /// The connection to the server - var connection: (any PhoenixTransport)? = nil - - /// The HTTPClient to perform HTTP requests. - let http: any HTTPClientType - - var accessToken: String? - - // ---------------------------------------------------------------------- - - // MARK: - Initialization - - // ---------------------------------------------------------------------- - @available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *) - public convenience init( - _ endPoint: String, - headers: [String: String] = [:], - params: Payload? = nil, - vsn: String = Defaults.vsn - ) { - self.init( - endPoint: endPoint, - headers: headers, - transport: { url in URLSessionTransport(url: url) }, - paramsClosure: { params }, - vsn: vsn - ) - } - - @available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *) - public convenience init( - _ endPoint: String, - headers: [String: String] = [:], - paramsClosure: PayloadClosure?, - vsn: String = Defaults.vsn - ) { - self.init( - endPoint: endPoint, - headers: headers, - transport: { url in URLSessionTransport(url: url) }, - paramsClosure: paramsClosure, - vsn: vsn - ) - } - - public init( - endPoint: String, - headers: [String: String] = [:], - transport: @escaping ((URL) -> any PhoenixTransport), - paramsClosure: PayloadClosure? = nil, - vsn: String = Defaults.vsn - ) { - self.transport = transport - self.paramsClosure = paramsClosure - self.endPoint = endPoint - self.vsn = vsn - - var headers = headers - if headers["X-Client-Info"] == nil { - headers["X-Client-Info"] = "realtime-swift/\(version)" - } - self.headers = headers - http = HTTPClient(fetch: { try await URLSession.shared.data(for: $0) }, interceptors: []) - - let params = paramsClosure?() - if let jwt = (params?["Authorization"] as? String)?.split(separator: " ").last { - accessToken = String(jwt) - } else { - accessToken = params?["apikey"] as? String - } - endPointUrl = RealtimeClient.buildEndpointUrl( - endpoint: endPoint, - paramsClosure: paramsClosure, - vsn: vsn - ) - - reconnectTimer = TimeoutTimer() - reconnectTimer.callback.delegate(to: self) { (self) in - self.logItems("Socket attempting to reconnect") - self.teardown(reason: "reconnection") { self.connect() } - } - reconnectTimer.timerCalculation - .delegate(to: self) { (self, tries) -> TimeInterval in - let interval = self.reconnectAfter(tries) - self.logItems("Socket reconnecting in \(interval)s") - return interval - } - } - - deinit { - reconnectTimer.reset() - } - - // ---------------------------------------------------------------------- - - // MARK: - Public - - // ---------------------------------------------------------------------- - /// - return: The socket protocol, wss or ws - public var websocketProtocol: String { - switch endPointUrl.scheme { - case "https": "wss" - case "http": "ws" - default: endPointUrl.scheme ?? "" - } - } - - /// - return: True if the socket is connected - public var isConnected: Bool { - connectionState == .open - } - - /// - return: The state of the connect. [.connecting, .open, .closing, .closed] - public var connectionState: PhoenixTransportReadyState { - connection?.readyState ?? .closed - } - - /// Sets the JWT access token used for channel subscription authorization and Realtime RLS. - /// - Parameter token: A JWT string. - public func setAuth(_ token: String?) { - accessToken = token - - for channel in channels { - if token != nil { - channel.params["user_token"] = token - } - - if channel.joinedOnce, channel.isJoined { - channel.push(ChannelEvent.accessToken, payload: ["access_token": token as Any]) - } - } - } - - /// Connects the Socket. The params passed to the Socket on initialization - /// will be sent through the connection. If the Socket is already connected, - /// then this call will be ignored. - public func connect() { - // Do not attempt to reconnect if the socket is currently connected - guard !isConnected else { return } - - // Reset the close status when attempting to connect - closeStatus = .unknown - - // We need to build this right before attempting to connect as the - // parameters could be built upon demand and change over time - endPointUrl = RealtimeClient.buildEndpointUrl( - endpoint: endPoint, - paramsClosure: paramsClosure, - vsn: vsn - ) - - connection = transport(endPointUrl) - connection?.delegate = self - // self.connection?.disableSSLCertValidation = disableSSLCertValidation - // - // #if os(Linux) - // #else - // self.connection?.security = security - // self.connection?.enabledSSLCipherSuites = enabledSSLCipherSuites - // #endif - - connection?.connect(with: headers) - } - - /// Disconnects the socket - /// - /// - parameter code: Optional. Closing status code - /// - parameter callback: Optional. Called when disconnected - public func disconnect( - code: CloseCode = CloseCode.normal, - reason: String? = nil, - callback: (() -> Void)? = nil - ) { - // The socket was closed cleanly by the User - closeStatus = CloseStatus(closeCode: code.rawValue) - - // Reset any reconnects and teardown the socket connection - reconnectTimer.reset() - teardown(code: code, reason: reason, callback: callback) - } - - func teardown( - code: CloseCode = CloseCode.normal, reason: String? = nil, callback: (() -> Void)? = nil - ) { - connection?.delegate = nil - connection?.disconnect(code: code.rawValue, reason: reason) - connection = nil - - // The socket connection has been turndown, heartbeats are not needed - heartbeatTimer?.stop() - - // Since the connection's delegate was nil'd out, inform all state - // callbacks that the connection has closed - stateChangeCallbacks.close.value.forEach { $0.callback.call((code.rawValue, reason)) } - callback?() - } - - // ---------------------------------------------------------------------- - - // MARK: - Register Socket State Callbacks - - // ---------------------------------------------------------------------- - - /// Registers callbacks for connection open events. Does not handle retain - /// cycles. Use `delegateOnOpen(to:)` for automatic handling of retain cycles. - /// - /// Example: - /// - /// socket.onOpen() { [weak self] in - /// self?.print("Socket Connection Open") - /// } - /// - /// - parameter callback: Called when the Socket is opened - @discardableResult - public func onOpen(callback: @escaping () -> Void) -> String { - onOpen { _ in callback() } - } - - /// Registers callbacks for connection open events. Does not handle retain - /// cycles. Use `delegateOnOpen(to:)` for automatic handling of retain cycles. - /// - /// Example: - /// - /// socket.onOpen() { [weak self] response in - /// self?.print("Socket Connection Open") - /// } - /// - /// - parameter callback: Called when the Socket is opened - @discardableResult - public func onOpen(callback: @escaping (URLResponse?) -> Void) -> String { - var delegated = Delegated() - delegated.manuallyDelegate(with: callback) - - return stateChangeCallbacks.open.withValue { [delegated] in - self.append(callback: delegated, to: &$0) - } - } - - /// Registers callbacks for connection open events. Automatically handles - /// retain cycles. Use `onOpen()` to handle yourself. - /// - /// Example: - /// - /// socket.delegateOnOpen(to: self) { self in - /// self.print("Socket Connection Open") - /// } - /// - /// - parameter owner: Class registering the callback. Usually `self` - /// - parameter callback: Called when the Socket is opened - @discardableResult - public func delegateOnOpen( - to owner: T, - callback: @escaping ((T) -> Void) - ) -> String { - delegateOnOpen(to: owner) { owner, _ in callback(owner) } - } - - /// Registers callbacks for connection open events. Automatically handles - /// retain cycles. Use `onOpen()` to handle yourself. - /// - /// Example: - /// - /// socket.delegateOnOpen(to: self) { self, response in - /// self.print("Socket Connection Open") - /// } - /// - /// - parameter owner: Class registering the callback. Usually `self` - /// - parameter callback: Called when the Socket is opened - @discardableResult - public func delegateOnOpen( - to owner: T, - callback: @escaping ((T, URLResponse?) -> Void) - ) -> String { - var delegated = Delegated() - delegated.delegate(to: owner, with: callback) - - return stateChangeCallbacks.open.withValue { [delegated] in - self.append(callback: delegated, to: &$0) - } - } - - /// Registers callbacks for connection close events. Does not handle retain - /// cycles. Use `delegateOnClose(_:)` for automatic handling of retain cycles. - /// - /// Example: - /// - /// socket.onClose() { [weak self] in - /// self?.print("Socket Connection Close") - /// } - /// - /// - parameter callback: Called when the Socket is closed - @discardableResult - public func onClose(callback: @escaping () -> Void) -> String { - onClose { _, _ in callback() } - } - - /// Registers callbacks for connection close events. Does not handle retain - /// cycles. Use `delegateOnClose(_:)` for automatic handling of retain cycles. - /// - /// Example: - /// - /// socket.onClose() { [weak self] code, reason in - /// self?.print("Socket Connection Close") - /// } - /// - /// - parameter callback: Called when the Socket is closed - @discardableResult - public func onClose(callback: @escaping (Int, String?) -> Void) -> String { - var delegated = Delegated<(Int, String?), Void>() - delegated.manuallyDelegate(with: callback) - - return stateChangeCallbacks.close.withValue { [delegated] in - self.append(callback: delegated, to: &$0) - } - } - - /// Registers callbacks for connection close events. Automatically handles - /// retain cycles. Use `onClose()` to handle yourself. - /// - /// Example: - /// - /// socket.delegateOnClose(self) { self in - /// self.print("Socket Connection Close") - /// } - /// - /// - parameter owner: Class registering the callback. Usually `self` - /// - parameter callback: Called when the Socket is closed - @discardableResult - public func delegateOnClose( - to owner: T, - callback: @escaping ((T) -> Void) - ) -> String { - delegateOnClose(to: owner) { owner, _ in callback(owner) } - } - - /// Registers callbacks for connection close events. Automatically handles - /// retain cycles. Use `onClose()` to handle yourself. - /// - /// Example: - /// - /// socket.delegateOnClose(self) { self, code, reason in - /// self.print("Socket Connection Close") - /// } - /// - /// - parameter owner: Class registering the callback. Usually `self` - /// - parameter callback: Called when the Socket is closed - @discardableResult - public func delegateOnClose( - to owner: T, - callback: @escaping ((T, (Int, String?)) -> Void) - ) -> String { - var delegated = Delegated<(Int, String?), Void>() - delegated.delegate(to: owner, with: callback) - - return stateChangeCallbacks.close.withValue { [delegated] in - self.append(callback: delegated, to: &$0) - } - } - - /// Registers callbacks for connection error events. Does not handle retain - /// cycles. Use `delegateOnError(to:)` for automatic handling of retain cycles. - /// - /// Example: - /// - /// socket.onError() { [weak self] (error) in - /// self?.print("Socket Connection Error", error) - /// } - /// - /// - parameter callback: Called when the Socket errors - @discardableResult - public func onError(callback: @escaping ((any Error, URLResponse?)) -> Void) -> String { - var delegated = Delegated<(any Error, URLResponse?), Void>() - delegated.manuallyDelegate(with: callback) - - return stateChangeCallbacks.error.withValue { [delegated] in - self.append(callback: delegated, to: &$0) - } - } - - /// Registers callbacks for connection error events. Automatically handles - /// retain cycles. Use `manualOnError()` to handle yourself. - /// - /// Example: - /// - /// socket.delegateOnError(to: self) { (self, error) in - /// self.print("Socket Connection Error", error) - /// } - /// - /// - parameter owner: Class registering the callback. Usually `self` - /// - parameter callback: Called when the Socket errors - @discardableResult - public func delegateOnError( - to owner: T, - callback: @escaping ((T, (any Error, URLResponse?)) -> Void) - ) -> String { - var delegated = Delegated<(any Error, URLResponse?), Void>() - delegated.delegate(to: owner, with: callback) - - return stateChangeCallbacks.error.withValue { [delegated] in - self.append(callback: delegated, to: &$0) - } - } - - /// Registers callbacks for connection message events. Does not handle - /// retain cycles. Use `delegateOnMessage(_to:)` for automatic handling of - /// retain cycles. - /// - /// Example: - /// - /// socket.onMessage() { [weak self] (message) in - /// self?.print("Socket Connection Message", message) - /// } - /// - /// - parameter callback: Called when the Socket receives a message event - @discardableResult - public func onMessage(callback: @escaping (RealtimeMessage) -> Void) -> String { - var delegated = Delegated() - delegated.manuallyDelegate(with: callback) - - return stateChangeCallbacks.message.withValue { [delegated] in - append(callback: delegated, to: &$0) - } - } - - /// Registers callbacks for connection message events. Automatically handles - /// retain cycles. Use `onMessage()` to handle yourself. - /// - /// Example: - /// - /// socket.delegateOnMessage(self) { (self, message) in - /// self.print("Socket Connection Message", message) - /// } - /// - /// - parameter owner: Class registering the callback. Usually `self` - /// - parameter callback: Called when the Socket receives a message event - @discardableResult - public func delegateOnMessage( - to owner: T, - callback: @escaping ((T, RealtimeMessage) -> Void) - ) -> String { - var delegated = Delegated() - delegated.delegate(to: owner, with: callback) - - return stateChangeCallbacks.message.withValue { [delegated] in - self.append(callback: delegated, to: &$0) - } - } - - private func append(callback: T, to array: inout [(ref: String, callback: T)]) - -> String - { - let ref = makeRef() - array.append((ref, callback)) - return ref - } - - /// Releases all stored callback hooks (onError, onOpen, onClose, etc.) You should - /// call this method when you are finished when the Socket in order to release - /// any references held by the socket. - public func releaseCallbacks() { - stateChangeCallbacks.open.setValue([]) - stateChangeCallbacks.close.setValue([]) - stateChangeCallbacks.error.setValue([]) - stateChangeCallbacks.message.setValue([]) - } - - // ---------------------------------------------------------------------- - - // MARK: - Channel Initialization - - // ---------------------------------------------------------------------- - /// Initialize a new Channel - /// - /// Example: - /// - /// let channel = socket.channel("rooms", params: ["user_id": "abc123"]) - /// - /// - parameter topic: Topic of the channel - /// - parameter params: Optional. Parameters for the channel - /// - return: A new channel - public func channel( - _ topic: String, - params: RealtimeChannelOptions = .init() - ) -> RealtimeChannel { - let channel = RealtimeChannel( - topic: "realtime:\(topic)", params: params.params, socket: self - ) - channels.append(channel) - - return channel - } - - /// Unsubscribes and removes a single channel - public func remove(_ channel: RealtimeChannel) { - channel.unsubscribe() - off(channel.stateChangeRefs) - channels.removeAll(where: { $0.joinRef == channel.joinRef }) - - if channels.isEmpty { - disconnect() - } - } - - /// Unsubscribes and removes all channels - public func removeAllChannels() { - for channel in channels { - remove(channel) - } - } - - /// Removes `onOpen`, `onClose`, `onError,` and `onMessage` registrations. - /// - /// - /// - Parameter refs: List of refs returned by calls to `onOpen`, `onClose`, etc - public func off(_ refs: [String]) { - stateChangeCallbacks.open.withValue { - $0 = $0.filter { - !refs.contains($0.ref) - } - } - stateChangeCallbacks.close.withValue { - $0 = $0.filter { - !refs.contains($0.ref) - } - } - stateChangeCallbacks.error.withValue { - $0 = $0.filter { - !refs.contains($0.ref) - } - } - stateChangeCallbacks.message.withValue { - $0 = $0.filter { - !refs.contains($0.ref) - } - } - } - - // ---------------------------------------------------------------------- - - // MARK: - Sending Data - - // ---------------------------------------------------------------------- - /// Sends data through the Socket. This method is internal. Instead, you - /// should call `push(_:, payload:, timeout:)` on the Channel you are - /// sending an event to. - /// - /// - parameter topic: - /// - parameter event: - /// - parameter payload: - /// - parameter ref: Optional. Defaults to nil - /// - parameter joinRef: Optional. Defaults to nil - func push( - topic: String, - event: String, - payload: Payload, - ref: String? = nil, - joinRef: String? = nil - ) { - let callback: (() throws -> Void) = { [weak self] in - guard let self else { return } - let body: [Any?] = [joinRef, ref, topic, event, payload] - let data = encode(body) - - logItems("push", "Sending \(String(data: data, encoding: String.Encoding.utf8) ?? "")") - connection?.send(data: data) - } - - /// If the socket is connected, then execute the callback immediately. - if isConnected { - try? callback() - } else { - /// If the socket is not connected, add the push to a buffer which will - /// be sent immediately upon connection. - sendBuffer.append((ref: ref, callback: callback)) - } - } - - /// - return: the next message ref, accounting for overflows - public func makeRef() -> String { - ref = (ref == UInt64.max) ? 0 : ref + 1 - return String(ref) - } - - /// Logs the message. Override Socket.logger for specialized logging. noops by default - /// - /// - parameter items: List of items to be logged. Behaves just like debugPrint() - func logItems(_ items: Any...) { - let msg = items.map { String(describing: $0) }.joined(separator: ", ") - logger?("SwiftPhoenixClient: \(msg)") - } - - // ---------------------------------------------------------------------- - - // MARK: - Connection Events - - // ---------------------------------------------------------------------- - /// Called when the underlying Websocket connects to it's host - func onConnectionOpen(response: URLResponse?) { - logItems("transport", "Connected to \(endPoint)") - - // Reset the close status now that the socket has been connected - closeStatus = .unknown - - // Send any messages that were waiting for a connection - flushSendBuffer() - - // Reset how the socket tried to reconnect - reconnectTimer.reset() - - // Restart the heartbeat timer - resetHeartbeat() - - // Inform all onOpen callbacks that the Socket has opened - stateChangeCallbacks.open.value.forEach { $0.callback.call(response) } - } - - func onConnectionClosed(code: Int, reason: String?) { - logItems("transport", "close") - - // Send an error to all channels - triggerChannelError() - - // Prevent the heartbeat from triggering if the - heartbeatTimer?.stop() - - // Only attempt to reconnect if the socket did not close normally, - // or if it was closed abnormally but on client side (e.g. due to heartbeat timeout) - if closeStatus.shouldReconnect { - reconnectTimer.scheduleTimeout() - } - - stateChangeCallbacks.close.value.forEach { $0.callback.call((code, reason)) } - } - - func onConnectionError(_ error: any Error, response: URLResponse?) { - logItems("transport", error, response ?? "") - - // Send an error to all channels - triggerChannelError() - - // Inform any state callbacks of the error - stateChangeCallbacks.error.value.forEach { $0.callback.call((error, response)) } - } - - func onConnectionMessage(_ rawMessage: String) { - logItems("receive ", rawMessage) - - guard - let data = rawMessage.data(using: String.Encoding.utf8), - let json = decode(data) as? [Any?], - let message = RealtimeMessage(json: json) - else { - logItems("receive: Unable to parse JSON: \(rawMessage)") - return - } - - // Clear heartbeat ref, preventing a heartbeat timeout disconnect - if message.ref == pendingHeartbeatRef { pendingHeartbeatRef = nil } - - if message.event == "phx_close" { - print("Close Event Received") - } - - // Dispatch the message to all channels that belong to the topic - channels - .filter { $0.isMember(message) } - .forEach { $0.trigger(message) } - - // Inform all onMessage callbacks of the message - stateChangeCallbacks.message.value.forEach { $0.callback.call(message) } - } - - /// Triggers an error event to all of the connected Channels - func triggerChannelError() { - for channel in channels { - // Only trigger a channel error if it is in an "opened" state - if !(channel.isErrored || channel.isLeaving || channel.isClosed) { - channel.trigger(event: ChannelEvent.error) - } - } - } - - /// Send all messages that were buffered before the socket opened - func flushSendBuffer() { - guard isConnected, sendBuffer.count > 0 else { return } - sendBuffer.forEach { try? $0.callback() } - sendBuffer = [] - } - - /// Removes an item from the sendBuffer with the matching ref - func removeFromSendBuffer(ref: String) { - sendBuffer = sendBuffer.filter { $0.ref != ref } - } - - /// Builds a fully qualified socket `URL` from `endPoint` and `params`. - static func buildEndpointUrl( - endpoint: String, paramsClosure params: PayloadClosure?, vsn: String - ) -> URL { - guard - let url = URL(string: endpoint), - var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) - else { fatalError("Malformed URL: \(endpoint)") } - - // Ensure that the URL ends with "/websocket - if !urlComponents.path.contains("/websocket") { - // Do not duplicate '/' in the path - if urlComponents.path.last != "/" { - urlComponents.path.append("/") - } - - // append 'websocket' to the path - urlComponents.path.append("websocket") - } - - urlComponents.queryItems = [URLQueryItem(name: "vsn", value: vsn)] - - // If there are parameters, append them to the URL - if let params = params?() { - urlComponents.queryItems?.append( - contentsOf: params.map { - URLQueryItem(name: $0.key, value: String(describing: $0.value)) - } - ) - } - - guard let qualifiedUrl = urlComponents.url - else { fatalError("Malformed URL while adding parameters") } - return qualifiedUrl - } - - // Leaves any channel that is open that has a duplicate topic - func leaveOpenTopic(topic: String) { - guard - let dupe = channels.first(where: { $0.topic == topic && ($0.isJoined || $0.isJoining) }) - else { return } - - logItems("transport", "leaving duplicate topic: [\(topic)]") - dupe.unsubscribe() - } - - // ---------------------------------------------------------------------- - - // MARK: - Heartbeat - - // ---------------------------------------------------------------------- - func resetHeartbeat() { - // Clear anything related to the heartbeat - pendingHeartbeatRef = nil - heartbeatTimer?.stop() - - // Do not start up the heartbeat timer if skipHeartbeat is true - guard !skipHeartbeat else { return } - - heartbeatTimer = HeartbeatTimer(timeInterval: heartbeatInterval, leeway: heartbeatLeeway) - heartbeatTimer?.start(eventHandler: { [weak self] in - self?.sendHeartbeat() - }) - } - - /// Sends a heartbeat payload to the phoenix servers - func sendHeartbeat() { - // Do not send if the connection is closed - guard isConnected else { return } - - // If there is a pending heartbeat ref, then the last heartbeat was - // never acknowledged by the server. Close the connection and attempt - // to reconnect. - if let _ = pendingHeartbeatRef { - pendingHeartbeatRef = nil - logItems( - "transport", - "heartbeat timeout. Attempting to re-establish connection" - ) - - // Close the socket manually, flagging the closure as abnormal. Do not use - // `teardown` or `disconnect` as they will nil out the websocket delegate. - abnormalClose("heartbeat timeout") - - return - } - - // The last heartbeat was acknowledged by the server. Send another one - pendingHeartbeatRef = makeRef() - push( - topic: "phoenix", - event: ChannelEvent.heartbeat, - payload: [:], - ref: pendingHeartbeatRef - ) - } - - func abnormalClose(_ reason: String) { - closeStatus = .abnormal - - /* - We use NORMAL here since the client is the one determining to close the - connection. However, we set to close status to abnormal so that - the client knows that it should attempt to reconnect. - - If the server subsequently acknowledges with code 1000 (normal close), - the socket will keep the `.abnormal` close status and trigger a reconnection. - */ - connection?.disconnect(code: CloseCode.normal.rawValue, reason: reason) - } - - // ---------------------------------------------------------------------- - - // MARK: - TransportDelegate - - // ---------------------------------------------------------------------- - public func onOpen(response: URLResponse?) { - onConnectionOpen(response: response) - } - - public func onError(error: any Error, response: URLResponse?) { - onConnectionError(error, response: response) - } - - public func onMessage(message: String) { - onConnectionMessage(message) - } - - public func onClose(code: Int, reason: String? = nil) { - closeStatus.update(transportCloseCode: code) - onConnectionClosed(code: code, reason: reason) - } -} - -// ---------------------------------------------------------------------- - -// MARK: - Close Codes - -// ---------------------------------------------------------------------- -extension RealtimeClient { - public enum CloseCode: Int { - case abnormal = 999 - - case normal = 1000 - - case goingAway = 1001 - } -} - -// ---------------------------------------------------------------------- - -// MARK: - Close Status - -// ---------------------------------------------------------------------- -extension RealtimeClient { - /// Indicates the different closure states a socket can be in. - enum CloseStatus { - /// Undetermined closure state - case unknown - /// A clean closure requested either by the client or the server - case clean - /// An abnormal closure requested by the client - case abnormal - - /// Temporarily close the socket, pausing reconnect attempts. Useful on mobile - /// clients when disconnecting a because the app resigned active but should - /// reconnect when app enters active state. - case temporary - - init(closeCode: Int) { - switch closeCode { - case CloseCode.abnormal.rawValue: - self = .abnormal - case CloseCode.goingAway.rawValue: - self = .temporary - default: - self = .clean - } - } - - mutating func update(transportCloseCode: Int) { - switch self { - case .unknown, .clean, .temporary: - // Allow transport layer to override these statuses. - self = .init(closeCode: transportCloseCode) - case .abnormal: - // Do not allow transport layer to override the abnormal close status. - // The socket itself should reset it on the next connection attempt. - // See `Socket.abnormalClose(_:)` for more information. - break - } - } - - var shouldReconnect: Bool { - switch self { - case .unknown, .abnormal: - true - case .clean, .temporary: - false - } - } - } -} diff --git a/Sources/Realtime/Deprecated/RealtimeMessage.swift b/Sources/Realtime/Deprecated/RealtimeMessage.swift deleted file mode 100644 index a993ae2d1..000000000 --- a/Sources/Realtime/Deprecated/RealtimeMessage.swift +++ /dev/null @@ -1,86 +0,0 @@ -// Copyright (c) 2021 David Stump -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -// THE SOFTWARE. - -import Foundation - -/// Data that is received from the Server. -public struct RealtimeMessage { - /// Reference number. Empty if missing - public let ref: String - - /// Join Reference number - let joinRef: String? - - /// Message topic - public let topic: String - - /// Message event - public let event: String - - /// The raw payload from the Message, including a nested response from - /// phx_reply events. It is recommended to use `payload` instead. - let rawPayload: Payload - - /// Message payload - public var payload: Payload { - guard let response = rawPayload["response"] as? Payload - else { return rawPayload } - return response - } - - /// Convenience accessor. Equivalent to getting the status as such: - /// ```swift - /// message.payload["status"] - /// ``` - public var status: PushStatus? { - (rawPayload["status"] as? String).flatMap(PushStatus.init(rawValue:)) - } - - init( - ref: String = "", - topic: String = "", - event: String = "", - payload: Payload = [:], - joinRef: String? = nil - ) { - self.ref = ref - self.topic = topic - self.event = event - rawPayload = payload - self.joinRef = joinRef - } - - init?(json: [Any?]) { - guard json.count > 4 else { return nil } - joinRef = json[0] as? String - ref = json[1] as? String ?? "" - - if let topic = json[2] as? String, - let event = json[3] as? String, - let payload = json[4] as? Payload - { - self.topic = topic - self.event = event - rawPayload = payload - } else { - return nil - } - } -} diff --git a/Sources/Realtime/Deprecated/TimeoutTimer.swift b/Sources/Realtime/Deprecated/TimeoutTimer.swift deleted file mode 100644 index b6b37c4c7..000000000 --- a/Sources/Realtime/Deprecated/TimeoutTimer.swift +++ /dev/null @@ -1,108 +0,0 @@ -// Copyright (c) 2021 David Stump -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -// THE SOFTWARE. - -/// Creates a timer that can perform calculated reties by setting -/// `timerCalculation` , such as exponential backoff. -/// -/// ### Example -/// -/// let reconnectTimer = TimeoutTimer() -/// -/// // Receive a callbcak when the timer is fired -/// reconnectTimer.callback.delegate(to: self) { (_) in -/// print("timer was fired") -/// } -/// -/// // Provide timer interval calculation -/// reconnectTimer.timerCalculation.delegate(to: self) { (_, tries) -> TimeInterval in -/// return tries > 2 ? 1000 : [1000, 5000, 10000][tries - 1] -/// } -/// -/// reconnectTimer.scheduleTimeout() // fires after 1000ms -/// reconnectTimer.scheduleTimeout() // fires after 5000ms -/// reconnectTimer.reset() -/// reconnectTimer.scheduleTimeout() // fires after 1000ms - -import Foundation - -// sourcery: AutoMockable -class TimeoutTimer { - /// Callback to be informed when the underlying Timer fires - var callback = Delegated() - - /// Provides TimeInterval to use when scheduling the timer - var timerCalculation = Delegated() - - /// The work to be done when the queue fires - var workItem: DispatchWorkItem? - - /// The number of times the underlyingTimer hass been set off. - var tries: Int = 0 - - /// The Queue to execute on. In testing, this is overridden - var queue: TimerQueue = .main - - /// Resets the Timer, clearing the number of tries and stops - /// any scheduled timeout. - func reset() { - tries = 0 - clearTimer() - } - - /// Schedules a timeout callback to fire after a calculated timeout duration. - func scheduleTimeout() { - // Clear any ongoing timer, not resetting the number of tries - clearTimer() - - // Get the next calculated interval, in milliseconds. Do not - // start the timer if the interval is returned as nil. - guard let timeInterval = timerCalculation.call(tries + 1) else { return } - - let workItem = DispatchWorkItem { - self.tries += 1 - self.callback.call() - } - - self.workItem = workItem - queue.queue(timeInterval: timeInterval, execute: workItem) - } - - /// Invalidates any ongoing Timer. Will not clear how many tries have been made - private func clearTimer() { - workItem?.cancel() - workItem = nil - } -} - -/// Wrapper class around a DispatchQueue. Allows for providing a fake clock -/// during tests. -class TimerQueue { - // Can be overriden in tests - static var main = TimerQueue() - - func queue(timeInterval: TimeInterval, execute: DispatchWorkItem) { - // TimeInterval is always in seconds. Multiply it by 1000 to convert - // to milliseconds and round to the nearest millisecond. - let dispatchInterval = Int(round(timeInterval * 1000)) - - let dispatchTime = DispatchTime.now() + .milliseconds(dispatchInterval) - DispatchQueue.main.asyncAfter(deadline: dispatchTime, execute: execute) - } -} diff --git a/Sources/Realtime/Types.swift b/Sources/Realtime/Types.swift index cd0a44c3a..63bffd3f4 100644 --- a/Sources/Realtime/Types.swift +++ b/Sources/Realtime/Types.swift @@ -121,3 +121,29 @@ struct BroadcastMessagePayload: Encodable { let `private`: Bool } } + +/// Represents the different events that can be sent through +/// a channel regarding a Channel's lifecycle. +enum ChannelEvent { + static let join = "phx_join" + static let leave = "phx_leave" + static let close = "phx_close" + static let error = "phx_error" + static let reply = "phx_reply" + static let system = "system" + static let broadcast = "broadcast" + static let accessToken = "access_token" + static let presence = "presence" + static let presenceDiff = "presence_diff" + static let presenceState = "presence_state" + static let postgresChanges = "postgres_changes" + + static let heartbeat = "heartbeat" + + static func isLifecyleEvent(_ event: String) -> Bool { + switch event { + case join, leave, reply, error, close: true + default: false + } + } +} diff --git a/Sources/Supabase/Deprecated.swift b/Sources/Supabase/Deprecated.swift index 5043e4119..bd58c9849 100644 --- a/Sources/Supabase/Deprecated.swift +++ b/Sources/Supabase/Deprecated.swift @@ -17,10 +17,4 @@ extension SupabaseClient { public var database: PostgrestClient { rest } - - /// Realtime client for Supabase - @available(*, deprecated, message: "Use realtimeV2") - public var realtime: RealtimeClient { - _realtime.value - } } diff --git a/Sources/Supabase/SupabaseClient.swift b/Sources/Supabase/SupabaseClient.swift index b419a94e8..62bdb6122 100644 --- a/Sources/Supabase/SupabaseClient.swift +++ b/Sources/Supabase/SupabaseClient.swift @@ -68,8 +68,6 @@ public final class SupabaseClient: Sendable { } } - let _realtime: UncheckedSendable - /// Realtime client for Supabase public var realtimeV2: RealtimeClientV2 { mutableState.withValue { @@ -184,14 +182,6 @@ public final class SupabaseClient: Sendable { autoRefreshToken: options.auth.autoRefreshToken ) - _realtime = UncheckedSendable( - RealtimeClient( - supabaseURL.appendingPathComponent("/realtime/v1").absoluteString, - headers: _headers.dictionary, - params: _headers.dictionary - ) - ) - if options.auth.accessToken == nil { listenForAuthEvents() } @@ -244,7 +234,7 @@ public final class SupabaseClient: Sendable { /// Returns all Realtime channels. public var channels: [RealtimeChannelV2] { - Array(realtimeV2.subscriptions.values) + Array(realtimeV2.channels.values) } /// Creates a Realtime channel with Broadcast, Presence, and Postgres Changes. @@ -387,7 +377,6 @@ public final class SupabaseClient: Sendable { return nil } - realtime.setAuth(accessToken) await realtimeV2.setAuth(accessToken) } From 108a1d3e0629d1d0d89619b58a70a4c708d59291 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Wed, 29 Oct 2025 17:29:39 -0300 Subject: [PATCH 5/6] feat: drop Swift 5.10 (#835) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: drop Swift 5.10 * remove CI run from macOS legacy * fix * use fail-fast = false * trying to fix tests * fix: resolve Swift 6.0 type inference issues in RealtimeTests This commit fixes two Swift 6.0 compiler errors that were causing CI failures on Xcode 16.4: 1. In setUp(), explicitly unwrap the `client` implicitly unwrapped optional when captured in the wsTransport closure 2. In testBroadcastWithHTTP(), explicitly unwrap the `sut` implicitly unwrapped optional when captured in the mock response closure These changes are needed because Swift 6.0 is stricter about type inference with implicitly unwrapped optionals in closure capture lists. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * fix: resolve more Swift 6 type inference issues --------- Co-authored-by: Claude --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- .github/workflows/ci.yml | 36 +-------------- .../Examples/Profile/UserIdentityList.swift | 34 +++++++------- Examples/Package.swift | 2 +- Examples/README.md | 2 +- Package.swift | 5 +- Package@swift-6.1.swift | 2 +- README.md | 4 +- Sources/Auth/AuthClient.swift | 5 +- Sources/Storage/Helpers.swift | 44 ++++++------------ Sources/Storage/MultipartFormData.swift | 46 ++++++------------- Tests/AuthTests/AuthClientTests.swift | 12 ++--- Tests/AuthTests/MockHelpers.swift | 2 +- Tests/AuthTests/RequestsTests.swift | 2 +- Tests/AuthTests/SessionManagerTests.swift | 8 ++-- .../FunctionsTests/FunctionsClientTests.swift | 4 +- Tests/FunctionsTests/RequestTests.swift | 2 +- .../PostgRESTTests/BuildURLRequestTests.swift | 2 +- Tests/PostgRESTTests/PostgresQueryTests.swift | 4 +- .../RealtimeTests/CallbackManagerTests.swift | 12 ----- Tests/RealtimeTests/RealtimeTests.swift | 7 ++- Tests/RealtimeTests/_PushTests.swift | 8 ++-- .../StorageTests/StorageBucketAPITests.swift | 6 +-- Tests/StorageTests/SupabaseStorageTests.swift | 14 +++--- 24 files changed, 89 insertions(+), 176 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 006d6318e..6cc3dc18d 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -40,7 +40,7 @@ body: attributes: label: Swift Version description: What version of Swift are you using? - placeholder: ex. 5.10 + placeholder: ex. 6.0 validations: required: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3c33b468e..2544eec9e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,7 @@ jobs: name: xcodebuild (macOS latest) runs-on: macos-15 strategy: + fail-fast: false matrix: command: [test, ""] platform: [IOS, MACOS] @@ -81,41 +82,6 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} file: lcov.info - macos-legacy: - name: xcodebuild (macOS legacy) - runs-on: macos-14 - strategy: - matrix: - command: [test, ""] - platform: [IOS, MACOS, MAC_CATALYST] - xcode: ["15.4"] - include: - - { command: test, skip_release: 1 } - steps: - - uses: actions/checkout@v5 - - name: Select Xcode ${{ matrix.xcode }} - run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app - - name: List available devices - run: xcrun simctl list devices available - - name: Cache derived data - uses: actions/cache@v4 - with: - path: | - ~/.derivedData - key: | - deriveddata-xcodebuild-${{ matrix.platform }}-${{ matrix.xcode }}-${{ matrix.command }}-${{ hashFiles('**/Sources/**/*.swift', '**/Tests/**/*.swift') }} - restore-keys: | - deriveddata-xcodebuild-${{ matrix.platform }}-${{ matrix.xcode }}-${{ matrix.command }}- - - name: Set IgnoreFileSystemDeviceInodeChanges flag - run: defaults write com.apple.dt.XCBuild IgnoreFileSystemDeviceInodeChanges -bool YES - - name: Update mtime for incremental builds - uses: chetan/git-restore-mtime-action@v2 - - name: Debug - run: make XCODEBUILD_ARGUMENT="${{ matrix.command }}" CONFIG=Debug PLATFORM="${{ matrix.platform }}" xcodebuild - - name: Release - if: matrix.skip_release != '1' - run: make XCODEBUILD_ARGUMENT="${{ matrix.command }}" CONFIG=Release PLATFORM="${{ matrix.platform }}" xcodebuild - spm: runs-on: macos-15 strategy: diff --git a/Examples/Examples/Profile/UserIdentityList.swift b/Examples/Examples/Profile/UserIdentityList.swift index 7635b6bd9..f7346fdce 100644 --- a/Examples/Examples/Profile/UserIdentityList.swift +++ b/Examples/Examples/Profile/UserIdentityList.swift @@ -237,30 +237,28 @@ struct UserIdentityList: View { } .id(id) .navigationTitle("Linked Identities") - #if swift(>=5.10) - .toolbar { - ToolbarItem(placement: .primaryAction) { - if !providers.isEmpty { - Menu { - ForEach(providers) { provider in - Button { - Task { - await linkProvider(provider) - } - } label: { - Label( - provider.rawValue.capitalized, - systemImage: iconForProvider(provider.rawValue) - ) + .toolbar { + ToolbarItem(placement: .primaryAction) { + if !providers.isEmpty { + Menu { + ForEach(providers) { provider in + Button { + Task { + await linkProvider(provider) } + } label: { + Label( + provider.rawValue.capitalized, + systemImage: iconForProvider(provider.rawValue) + ) } - } label: { - Label("Link Account", systemImage: "plus") } + } label: { + Label("Link Account", systemImage: "plus") } } } - #endif + } } private func iconForProvider(_ provider: String) -> String { diff --git a/Examples/Package.swift b/Examples/Package.swift index e75600e00..9a0b398e0 100644 --- a/Examples/Package.swift +++ b/Examples/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.7 +// swift-tools-version:6.0 // The swift-tools-version declares the minimum version of Swift required to build this package. import Foundation diff --git a/Examples/README.md b/Examples/README.md index 75839d254..6bc3f5879 100644 --- a/Examples/README.md +++ b/Examples/README.md @@ -171,7 +171,7 @@ Features: ## Prerequisites -- Xcode 15.0 or later +- Xcode 16.0 or later - iOS 17.0+ / macOS 14.0+ or later - [Supabase CLI](https://supabase.com/docs/guides/cli) installed diff --git a/Package.swift b/Package.swift index ade0d0fd2..bdf28f74c 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.10 +// swift-tools-version:6.0 // The swift-tools-version declares the minimum version of Swift required to build this package. import Foundation @@ -204,7 +204,8 @@ let package = Package( "Mocker", ] ), - ] + ], + swiftLanguageModes: [.v6] ) for target in package.targets where !target.isTest { diff --git a/Package@swift-6.1.swift b/Package@swift-6.1.swift index 75049b7d8..675d5b13d 100644 --- a/Package@swift-6.1.swift +++ b/Package@swift-6.1.swift @@ -204,7 +204,7 @@ let package = Package( ] ), ], - swiftLanguageModes: [.v5] + swiftLanguageModes: [.v6] ) for target in package.targets where !target.isTest { diff --git a/README.md b/README.md index f02defb57..9c48cdb0e 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,8 @@ Supabase SDK for Swift. Mirrors the design of [supabase-js](https://github.com/s ### Requirements - iOS 16.0+ / macOS 13+ / tvOS 16+ / watchOS 9+ / visionOS 1+ -- Xcode 15.3+ -- Swift 5.10+ +- Xcode 16.0+ +- Swift 6.0+ > [!IMPORTANT] > Check the [Support Policy](#support-policy) to learn when dropping Xcode, Swift, and platform versions will not be considered a **breaking change**. diff --git a/Sources/Auth/AuthClient.swift b/Sources/Auth/AuthClient.swift index 8a602d246..a42e8e7a9 100644 --- a/Sources/Auth/AuthClient.swift +++ b/Sources/Auth/AuthClient.swift @@ -60,7 +60,7 @@ private actor GlobalJWKSCache { private let globalJWKSCache = GlobalJWKSCache() public actor AuthClient { - static var globalClientID = 0 + static let globalClientID = LockIsolated(0) nonisolated let clientID: AuthClientID nonisolated private var api: APIClient { Dependencies[clientID].api } @@ -122,8 +122,7 @@ public actor AuthClient { /// - Parameters: /// - configuration: The client configuration. public init(configuration: Configuration) { - AuthClient.globalClientID += 1 - clientID = AuthClient.globalClientID + clientID = AuthClient.globalClientID.withValue { $0 += 1; return $0 } Dependencies[clientID] = Dependencies( configuration: configuration, diff --git a/Sources/Storage/Helpers.swift b/Sources/Storage/Helpers.swift index 95d3eb9ab..cadce9f12 100644 --- a/Sources/Storage/Helpers.swift +++ b/Sources/Storage/Helpers.swift @@ -17,39 +17,21 @@ import Foundation import UniformTypeIdentifiers func mimeType(forPathExtension pathExtension: String) -> String { - #if swift(>=5.9) - if #available(iOS 14, macOS 11, tvOS 14, watchOS 7, visionOS 1, *) { - return UTType(filenameExtension: pathExtension)?.preferredMIMEType - ?? "application/octet-stream" - } else { - if let id = UTTypeCreatePreferredIdentifierForTag( - kUTTagClassFilenameExtension, pathExtension as CFString, nil - )?.takeRetainedValue(), - let contentType = UTTypeCopyPreferredTagWithClass(id, kUTTagClassMIMEType)? - .takeRetainedValue() - { - return contentType as String - } - - return "application/octet-stream" + if #available(iOS 14, macOS 11, tvOS 14, watchOS 7, visionOS 1, *) { + return UTType(filenameExtension: pathExtension)?.preferredMIMEType + ?? "application/octet-stream" + } else { + if let id = UTTypeCreatePreferredIdentifierForTag( + kUTTagClassFilenameExtension, pathExtension as CFString, nil + )?.takeRetainedValue(), + let contentType = UTTypeCopyPreferredTagWithClass(id, kUTTagClassMIMEType)? + .takeRetainedValue() + { + return contentType as String } - #else - if #available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) { - return UTType(filenameExtension: pathExtension)?.preferredMIMEType - ?? "application/octet-stream" - } else { - if let id = UTTypeCreatePreferredIdentifierForTag( - kUTTagClassFilenameExtension, pathExtension as CFString, nil - )?.takeRetainedValue(), - let contentType = UTTypeCopyPreferredTagWithClass(id, kUTTagClassMIMEType)? - .takeRetainedValue() - { - return contentType as String - } - return "application/octet-stream" - } - #endif + return "application/octet-stream" + } } #else diff --git a/Sources/Storage/MultipartFormData.swift b/Sources/Storage/MultipartFormData.swift index 7fa45f2ff..446b166b0 100644 --- a/Sources/Storage/MultipartFormData.swift +++ b/Sources/Storage/MultipartFormData.swift @@ -569,39 +569,21 @@ class MultipartFormData { // MARK: - Private - Mime Type static func mimeType(forPathExtension pathExtension: String) -> String { - #if swift(>=5.9) - if #available(iOS 14, macOS 11, tvOS 14, watchOS 7, visionOS 1, *) { - return UTType(filenameExtension: pathExtension)?.preferredMIMEType - ?? "application/octet-stream" - } else { - if let id = UTTypeCreatePreferredIdentifierForTag( - kUTTagClassFilenameExtension, pathExtension as CFString, nil - )?.takeRetainedValue(), - let contentType = UTTypeCopyPreferredTagWithClass(id, kUTTagClassMIMEType)? - .takeRetainedValue() - { - return contentType as String - } - - return "application/octet-stream" - } - #else - if #available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) { - return UTType(filenameExtension: pathExtension)?.preferredMIMEType - ?? "application/octet-stream" - } else { - if let id = UTTypeCreatePreferredIdentifierForTag( - kUTTagClassFilenameExtension, pathExtension as CFString, nil - )?.takeRetainedValue(), - let contentType = UTTypeCopyPreferredTagWithClass(id, kUTTagClassMIMEType)? - .takeRetainedValue() - { - return contentType as String - } - - return "application/octet-stream" + if #available(iOS 14, macOS 11, tvOS 14, watchOS 7, visionOS 1, *) { + return UTType(filenameExtension: pathExtension)?.preferredMIMEType + ?? "application/octet-stream" + } else { + if let id = UTTypeCreatePreferredIdentifierForTag( + kUTTagClassFilenameExtension, pathExtension as CFString, nil + )?.takeRetainedValue(), + let contentType = UTTypeCopyPreferredTagWithClass(id, kUTTagClassMIMEType)? + .takeRetainedValue() + { + return contentType as String } - #endif + + return "application/octet-stream" + } } } diff --git a/Tests/AuthTests/AuthClientTests.swift b/Tests/AuthTests/AuthClientTests.swift index 99d27a0b5..48e7a5d84 100644 --- a/Tests/AuthTests/AuthClientTests.swift +++ b/Tests/AuthTests/AuthClientTests.swift @@ -190,8 +190,8 @@ final class AuthClientTests: XCTestCase { let validSession = Session.validSession Dependencies[sut.clientID].sessionStorage.store(validSession) - let eventsTask = Task { - await sut.authStateChanges.prefix(2).collect() + let eventsTask = Task { [sut] in + await sut!.authStateChanges.prefix(2).collect() } await Task.megaYield() @@ -236,8 +236,8 @@ final class AuthClientTests: XCTestCase { let validSession = Session.validSession Dependencies[sut.clientID].sessionStorage.store(validSession) - let eventsTask = Task { - await sut.authStateChanges.prefix(2).collect() + let eventsTask = Task { [sut] in + await sut!.authStateChanges.prefix(2).collect() } await Task.megaYield() @@ -282,8 +282,8 @@ final class AuthClientTests: XCTestCase { let validSession = Session.validSession Dependencies[sut.clientID].sessionStorage.store(validSession) - let eventsTask = Task { - await sut.authStateChanges.prefix(2).collect() + let eventsTask = Task { [sut] in + await sut!.authStateChanges.prefix(2).collect() } await Task.megaYield() diff --git a/Tests/AuthTests/MockHelpers.swift b/Tests/AuthTests/MockHelpers.swift index e5c3210cc..b53449fc0 100644 --- a/Tests/AuthTests/MockHelpers.swift +++ b/Tests/AuthTests/MockHelpers.swift @@ -16,7 +16,7 @@ extension Decodable { } extension Dependencies { - static var mock = Dependencies( + static let mock = Dependencies( configuration: AuthClient.Configuration( url: URL(string: "https://project-id.supabase.com")!, localStorage: InMemoryLocalStorage(), diff --git a/Tests/AuthTests/RequestsTests.swift b/Tests/AuthTests/RequestsTests.swift index 92c5b5aac..a8dcde072 100644 --- a/Tests/AuthTests/RequestsTests.swift +++ b/Tests/AuthTests/RequestsTests.swift @@ -509,7 +509,7 @@ final class RequestsTests: XCTestCase { record: Bool = false, flowType: AuthFlowType = .implicit, fetch: AuthClient.FetchHandler? = nil, - file: StaticString = #file, + file: StaticString = #filePath, testName: String = #function, line: UInt = #line ) -> AuthClient { diff --git a/Tests/AuthTests/SessionManagerTests.swift b/Tests/AuthTests/SessionManagerTests.swift index 3042419e4..8ac4a94c8 100644 --- a/Tests/AuthTests/SessionManagerTests.swift +++ b/Tests/AuthTests/SessionManagerTests.swift @@ -93,8 +93,8 @@ final class SessionManagerTests: XCTestCase { // Fire N tasks and call sut.session() let tasks = (0..<10).map { _ in - Task { [weak self] in - try await self?.sut.session() + Task { [sut] in + try await sut.session() } } @@ -104,7 +104,7 @@ final class SessionManagerTests: XCTestCase { refreshSessionContinuation.finish() // Await for all tasks to complete. - var result: [Result] = [] + var result: [Result] = [] for task in tasks { let value = await task.result result.append(value) @@ -113,7 +113,7 @@ final class SessionManagerTests: XCTestCase { // Verify that refresher and storage was called only once. expectNoDifference(refreshSessionCallCount.value, 1) expectNoDifference( - try result.map { try $0.get()?.accessToken }, + try result.map { try $0.get().accessToken }, (0..<10).map { _ in validSession.accessToken } ) } diff --git a/Tests/FunctionsTests/FunctionsClientTests.swift b/Tests/FunctionsTests/FunctionsClientTests.swift index 1b456236d..992fb5610 100644 --- a/Tests/FunctionsTests/FunctionsClientTests.swift +++ b/Tests/FunctionsTests/FunctionsClientTests.swift @@ -32,8 +32,8 @@ final class FunctionsClientTests: XCTestCase { "apikey": apiKey ], region: region.flatMap(FunctionRegion.init(rawValue:)), - fetch: { request in - try await self.session.data(for: request) + fetch: { [session] request in + try await session.data(for: request) }, sessionConfiguration: sessionConfiguration ) diff --git a/Tests/FunctionsTests/RequestTests.swift b/Tests/FunctionsTests/RequestTests.swift index 00b4c7896..c8c4a46a2 100644 --- a/Tests/FunctionsTests/RequestTests.swift +++ b/Tests/FunctionsTests/RequestTests.swift @@ -46,7 +46,7 @@ final class RequestTests: XCTestCase { func snapshot( record: Bool = false, _ test: (FunctionsClient) async throws -> Void, - file: StaticString = #file, + file: StaticString = #filePath, testName: String = #function, line: UInt = #line ) async { diff --git a/Tests/PostgRESTTests/BuildURLRequestTests.swift b/Tests/PostgRESTTests/BuildURLRequestTests.swift index 6c4cbf370..9ca6a63ad 100644 --- a/Tests/PostgRESTTests/BuildURLRequestTests.swift +++ b/Tests/PostgRESTTests/BuildURLRequestTests.swift @@ -27,7 +27,7 @@ final class BuildURLRequestTests: XCTestCase { init( name: String, record: Bool = false, - file: StaticString = #file, + file: StaticString = #filePath, line: UInt = #line, build: @escaping @Sendable (PostgrestClient) async throws -> PostgrestBuilder ) { diff --git a/Tests/PostgRESTTests/PostgresQueryTests.swift b/Tests/PostgRESTTests/PostgresQueryTests.swift index 16edcd95a..957f4a3ed 100644 --- a/Tests/PostgRESTTests/PostgresQueryTests.swift +++ b/Tests/PostgRESTTests/PostgresQueryTests.swift @@ -33,8 +33,8 @@ class PostgrestQueryTests: XCTestCase { "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" ], logger: nil, - fetch: { - try await self.session.data(for: $0) + fetch: { [session] in + try await session.data(for: $0) }, encoder: { let encoder = PostgrestClient.Configuration.jsonEncoder diff --git a/Tests/RealtimeTests/CallbackManagerTests.swift b/Tests/RealtimeTests/CallbackManagerTests.swift index d0b7441d5..d80269ccf 100644 --- a/Tests/RealtimeTests/CallbackManagerTests.swift +++ b/Tests/RealtimeTests/CallbackManagerTests.swift @@ -14,7 +14,6 @@ import XCTest final class CallbackManagerTests: XCTestCase { func testIntegration() { let callbackManager = CallbackManager() - XCTAssertNoLeak(callbackManager) let filter = PostgresJoinConfig( event: .update, @@ -50,7 +49,6 @@ final class CallbackManagerTests: XCTestCase { func testSetServerChanges() { let callbackManager = CallbackManager() - XCTAssertNoLeak(callbackManager) let changes = [ PostgresJoinConfig( @@ -69,7 +67,6 @@ final class CallbackManagerTests: XCTestCase { func testTriggerPostgresChanges() { let callbackManager = CallbackManager() - XCTAssertNoLeak(callbackManager) let updateUsersFilter = PostgresJoinConfig( event: .update, @@ -174,7 +171,6 @@ final class CallbackManagerTests: XCTestCase { func testTriggerBroadcast() throws { let callbackManager = CallbackManager() - XCTAssertNoLeak(callbackManager) let event = "new_user" let message = RealtimeMessageV2( @@ -250,11 +246,3 @@ final class CallbackManagerTests: XCTestCase { XCTAssertEqual(receivedMessage.value?.status, .ok) } } - -extension XCTestCase { - func XCTAssertNoLeak(_ object: AnyObject, file: StaticString = #file, line: UInt = #line) { - addTeardownBlock { [weak object] in - XCTAssertNil(object, file: file, line: line) - } - } -} diff --git a/Tests/RealtimeTests/RealtimeTests.swift b/Tests/RealtimeTests/RealtimeTests.swift index 5febd1269..9fbbdda44 100644 --- a/Tests/RealtimeTests/RealtimeTests.swift +++ b/Tests/RealtimeTests/RealtimeTests.swift @@ -11,7 +11,6 @@ import XCTest import FoundationNetworking #endif -@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) final class RealtimeTests: XCTestCase { let url = URL(string: "http://localhost:54321/realtime/v1")! let apiKey = "anon.api.key" @@ -50,7 +49,7 @@ final class RealtimeTests: XCTestCase { "custom.access.token" } ), - wsTransport: { _, _ in self.client }, + wsTransport: { [client] _, _ in client! }, http: http ) } @@ -578,11 +577,11 @@ final class RealtimeTests: XCTestCase { func testBroadcastWithHTTP() async throws { await http.when { $0.url.path.hasSuffix("broadcast") - } return: { _ in + } return: { [sut] _ in HTTPResponse( data: "{}".data(using: .utf8)!, response: HTTPURLResponse( - url: self.sut.broadcastURL, + url: sut!.broadcastURL, statusCode: 200, httpVersion: nil, headerFields: nil diff --git a/Tests/RealtimeTests/_PushTests.swift b/Tests/RealtimeTests/_PushTests.swift index ce901bb99..df85752a7 100644 --- a/Tests/RealtimeTests/_PushTests.swift +++ b/Tests/RealtimeTests/_PushTests.swift @@ -12,8 +12,6 @@ import XCTest @testable import Realtime #if !os(Android) && !os(Linux) && !os(Windows) - @MainActor - @available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) final class _PushTests: XCTestCase { var ws: FakeWebSocket! var socket: RealtimeClientV2! @@ -45,7 +43,7 @@ import XCTest socket: socket, logger: nil ) - let push = PushV2( + let push = await PushV2( channel: channel, message: RealtimeMessageV2( joinRef: nil, @@ -71,7 +69,7 @@ import XCTest socket: socket, logger: nil ) - let push = PushV2( + let push = await PushV2( channel: channel, message: RealtimeMessageV2( joinRef: nil, @@ -86,7 +84,7 @@ import XCTest await push.send() } await Task.megaYield() - push.didReceive(status: .ok) + await push.didReceive(status: .ok) let status = await task.value XCTAssertEqual(status, .ok) diff --git a/Tests/StorageTests/StorageBucketAPITests.swift b/Tests/StorageTests/StorageBucketAPITests.swift index d4de1cd4f..d60de7483 100644 --- a/Tests/StorageTests/StorageBucketAPITests.swift +++ b/Tests/StorageTests/StorageBucketAPITests.swift @@ -47,7 +47,7 @@ final class StorageBucketAPITests: XCTestCase { Mocker.removeAll() } - func testURLConstruction() { + func testURLConstruction() async { let urlTestCases = [ ( "https://blah.supabase.co/storage/v1", @@ -77,7 +77,7 @@ final class StorageBucketAPITests: XCTestCase { ] for (input, expect, description) in urlTestCases { - XCTContext.runActivity(named: "should \(description) if useNewHostname is true") { _ in + await XCTContext.runActivity(named: "should \(description) if useNewHostname is true") { _ in let storage = SupabaseStorageClient( configuration: StorageClientConfiguration( url: URL(string: input)!, @@ -88,7 +88,7 @@ final class StorageBucketAPITests: XCTestCase { XCTAssertEqual(storage.configuration.url.absoluteString, expect) } - XCTContext.runActivity(named: "should not modify host if useNewHostname is false") { _ in + await XCTContext.runActivity(named: "should not modify host if useNewHostname is false") { _ in let storage = SupabaseStorageClient( configuration: StorageClientConfiguration( url: URL(string: input)!, diff --git a/Tests/StorageTests/SupabaseStorageTests.swift b/Tests/StorageTests/SupabaseStorageTests.swift index cca842e5d..c44cf4c83 100644 --- a/Tests/StorageTests/SupabaseStorageTests.swift +++ b/Tests/StorageTests/SupabaseStorageTests.swift @@ -58,7 +58,7 @@ final class SupabaseStorageTests: XCTestCase { } func testCreateSignedURLs() async throws { - sessionMock.fetch = { _ in + sessionMock.fetch = { [supabaseURL] _ in ( """ [ @@ -71,7 +71,7 @@ final class SupabaseStorageTests: XCTestCase { ] """.data(using: .utf8)!, HTTPURLResponse( - url: self.supabaseURL, + url: supabaseURL, statusCode: 200, httpVersion: nil, headerFields: nil @@ -96,7 +96,7 @@ final class SupabaseStorageTests: XCTestCase { func testUploadData() async throws { testingBoundary.setValue("alamofire.boundary.c21f947c1c7b0c57") - sessionMock.fetch = { request in + sessionMock.fetch = { [supabaseURL] request in assertInlineSnapshot(of: request, as: .curl) { #""" curl \ @@ -133,7 +133,7 @@ final class SupabaseStorageTests: XCTestCase { } """.data(using: .utf8)!, HTTPURLResponse( - url: self.supabaseURL, + url: supabaseURL, statusCode: 200, httpVersion: nil, headerFields: nil @@ -157,7 +157,7 @@ final class SupabaseStorageTests: XCTestCase { func testUploadFileURL() async throws { testingBoundary.setValue("alamofire.boundary.c21f947c1c7b0c57") - sessionMock.fetch = { request in + sessionMock.fetch = { [supabaseURL] request in assertInlineSnapshot(of: request, as: .curl) { #""" curl \ @@ -179,7 +179,7 @@ final class SupabaseStorageTests: XCTestCase { } """.data(using: .utf8)!, HTTPURLResponse( - url: self.supabaseURL, + url: supabaseURL, statusCode: 200, httpVersion: nil, headerFields: nil @@ -209,7 +209,7 @@ final class SupabaseStorageTests: XCTestCase { } private func uploadFileURL(_ fileName: String) -> URL { - URL(fileURLWithPath: #file) + URL(fileURLWithPath: #filePath) .deletingLastPathComponent() .appendingPathComponent(fileName) } From 47f04810afd37fb5bd81434960ebb97b0f15bba4 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Wed, 29 Oct 2025 18:18:22 -0300 Subject: [PATCH 6/6] feat(auth): remove deprecated code (#838) --- Sources/Auth/AuthError.swift | 130 --------------- Sources/Auth/Deprecated.swift | 149 ------------------ Sources/Auth/Types.swift | 36 +---- Tests/AuthTests/AuthClientTests.swift | 8 +- Tests/AuthTests/RequestsTests.swift | 3 +- .../RequestsTests/testUpdateUser.1.txt | 2 +- 6 files changed, 12 insertions(+), 316 deletions(-) delete mode 100644 Sources/Auth/Deprecated.swift diff --git a/Sources/Auth/AuthError.swift b/Sources/Auth/AuthError.swift index 991100bf5..9e2da3e19 100644 --- a/Sources/Auth/AuthError.swift +++ b/Sources/Auth/AuthError.swift @@ -118,129 +118,6 @@ extension ErrorCode { } public enum AuthError: LocalizedError, Equatable { - @available( - *, - deprecated, - message: - "Error used to be thrown when no exp claim was found in JWT during setSession(accessToken:refreshToken:) method." - ) - case missingExpClaim - - @available( - *, - deprecated, - message: - "Error used to be thrown when provided JWT wasn't valid during setSession(accessToken:refreshToken:) method." - ) - case malformedJWT - - @available(*, deprecated, renamed: "sessionMissing") - public static var sessionNotFound: AuthError { .sessionMissing } - - /// Error thrown during PKCE flow. - @available( - *, - deprecated, - renamed: "pkceGrantCodeExchange", - message: "Error was grouped in `pkceGrantCodeExchange`, please use it instead of `pkce`." - ) - public static func pkce(_ reason: PKCEFailureReason) -> AuthError { - switch reason { - case .codeVerifierNotFound: - .pkceGrantCodeExchange(message: "A code verifier wasn't found in PKCE flow.") - case .invalidPKCEFlowURL: - .pkceGrantCodeExchange(message: "Not a valid PKCE flow url.") - } - } - - @available(*, deprecated, message: "Use `pkceGrantCodeExchange` instead.") - public enum PKCEFailureReason: Sendable { - /// Code verifier not found in the URL. - case codeVerifierNotFound - - /// Not a valid PKCE flow URL. - case invalidPKCEFlowURL - } - - @available(*, deprecated, renamed: "implicitGrantRedirect") - public static var invalidImplicitGrantFlowURL: AuthError { - .implicitGrantRedirect(message: "Not a valid implicit grant flow url.") - } - - @available( - *, - deprecated, - message: - "This error is never thrown, if you depend on it, you can remove the logic as it never happens." - ) - case missingURL - - @available( - *, - deprecated, - message: - "Error used to be thrown on methods which required a valid redirect scheme, such as signInWithOAuth. This is now considered a programming error an a assertion is triggered in case redirect scheme isn't provided." - ) - case invalidRedirectScheme - - @available( - *, - deprecated, - renamed: "api(message:errorCode:underlyingData:underlyingResponse:)" - ) - public static func api(_ error: APIError) -> AuthError { - let message = error.msg ?? error.error ?? error.errorDescription ?? "Unexpected API error." - if let weakPassword = error.weakPassword { - return .weakPassword(message: message, reasons: weakPassword.reasons) - } - - return .api( - message: message, - errorCode: .unknown, - underlyingData: (try? AuthClient.Configuration.jsonEncoder.encode(error)) ?? Data(), - underlyingResponse: HTTPURLResponse( - url: defaultAuthURL, - statusCode: error.code ?? 500, - httpVersion: nil, - headerFields: nil - )! - ) - } - - /// An error returned by the API. - @available( - *, - deprecated, - renamed: "api(message:errorCode:underlyingData:underlyingResponse:)" - ) - public struct APIError: Error, Codable, Sendable, Equatable { - /// A basic message describing the problem with the request. Usually missing if - /// ``AuthError/APIError/error`` is present. - public var msg: String? - - /// The HTTP status code. Usually missing if ``AuthError/APIError/error`` is present. - public var code: Int? - - /// Certain responses will contain this property with the provided values. - /// - /// Usually one of these: - /// - `invalid_request` - /// - `unauthorized_client` - /// - `access_denied` - /// - `server_error` - /// - `temporarily_unavailable` - /// - `unsupported_otp_type` - public var error: String? - - /// Certain responses that have an ``AuthError/APIError/error`` property may have this property - /// which describes the error. - public var errorDescription: String? - - /// Only returned when signing up if the password used is too weak. Inspect the - /// ``WeakPassword/reasons`` and ``AuthError/APIError/msg`` property to identify the causes. - public var weakPassword: WeakPassword? - } - /// Error thrown when a session is required to proceed, but none was found, either thrown by the client, or returned by the server. case sessionMissing @@ -274,11 +151,6 @@ public enum AuthError: LocalizedError, Equatable { let .implicitGrantRedirect(message), let .jwtVerificationFailed(message): message - // Deprecated cases - case .missingExpClaim: "Missing expiration claim in the access token." - case .malformedJWT: "A malformed JWT received." - case .invalidRedirectScheme: "Invalid redirect scheme." - case .missingURL: "Missing URL." } } @@ -289,8 +161,6 @@ public enum AuthError: LocalizedError, Equatable { case let .api(_, errorCode, _, _): errorCode case .pkceGrantCodeExchange, .implicitGrantRedirect: .unknown case .jwtVerificationFailed: .invalidJWT - // Deprecated cases - case .missingExpClaim, .malformedJWT, .invalidRedirectScheme, .missingURL: .unknown } } diff --git a/Sources/Auth/Deprecated.swift b/Sources/Auth/Deprecated.swift deleted file mode 100644 index 9b0ca5f24..000000000 --- a/Sources/Auth/Deprecated.swift +++ /dev/null @@ -1,149 +0,0 @@ -// -// Deprecated.swift -// -// -// Created by Guilherme Souza on 14/12/23. -// - -import Foundation - -#if canImport(FoundationNetworking) - import FoundationNetworking -#endif - -@available(*, deprecated, renamed: "AuthClient") -public typealias GoTrueClient = AuthClient - -@available(*, deprecated, renamed: "AuthMFA") -public typealias GoTrueMFA = AuthMFA - -@available(*, deprecated, renamed: "AuthLocalStorage") -public typealias GoTrueLocalStorage = AuthLocalStorage - -@available(*, deprecated, renamed: "AuthMetaSecurity") -public typealias GoTrueMetaSecurity = AuthMetaSecurity - -@available(*, deprecated, renamed: "AuthError") -public typealias GoTrueError = AuthError - -extension JSONEncoder { - @available( - *, - deprecated, - renamed: "AuthClient.Configuration.jsonEncoder", - message: - "Access to the default JSONEncoder instance moved to AuthClient.Configuration.jsonEncoder" - ) - public static var goTrue: JSONEncoder { - AuthClient.Configuration.jsonEncoder - } -} - -extension JSONDecoder { - @available( - *, - deprecated, - renamed: "AuthClient.Configuration.jsonDecoder", - message: - "Access to the default JSONDecoder instance moved to AuthClient.Configuration.jsonDecoder" - ) - public static var goTrue: JSONDecoder { - AuthClient.Configuration.jsonDecoder - } -} - -extension AuthClient.Configuration { - /// Initializes a AuthClient Configuration with optional parameters. - /// - /// - Parameters: - /// - url: The base URL of the Auth server. - /// - headers: Custom headers to be included in requests. - /// - flowType: The authentication flow type. - /// - localStorage: The storage mechanism for local data. - /// - encoder: The JSON encoder to use for encoding requests. - /// - decoder: The JSON decoder to use for decoding responses. - /// - fetch: The asynchronous fetch handler for network requests. - @available( - *, - deprecated, - message: - "Replace usages of this initializer with new init(url:headers:flowType:localStorage:logger:encoder:decoder:fetch)" - ) - public init( - url: URL, - headers: [String: String] = [:], - flowType: AuthFlowType = Self.defaultFlowType, - localStorage: any AuthLocalStorage, - encoder: JSONEncoder = AuthClient.Configuration.jsonEncoder, - decoder: JSONDecoder = AuthClient.Configuration.jsonDecoder, - fetch: @escaping AuthClient.FetchHandler = { try await URLSession.shared.data(for: $0) } - ) { - self.init( - url: url, - headers: headers, - flowType: flowType, - localStorage: localStorage, - logger: nil, - encoder: encoder, - decoder: decoder, - fetch: fetch - ) - } -} - -extension AuthClient { - /// Initializes a AuthClient Configuration with optional parameters. - /// - /// - Parameters: - /// - url: The base URL of the Auth server. - /// - headers: Custom headers to be included in requests. - /// - flowType: The authentication flow type. - /// - localStorage: The storage mechanism for local data. - /// - encoder: The JSON encoder to use for encoding requests. - /// - decoder: The JSON decoder to use for decoding responses. - /// - fetch: The asynchronous fetch handler for network requests. - @available( - *, - deprecated, - message: - "Replace usages of this initializer with new init(url:headers:flowType:localStorage:logger:encoder:decoder:fetch)" - ) - public init( - url: URL, - headers: [String: String] = [:], - flowType: AuthFlowType = Configuration.defaultFlowType, - localStorage: any AuthLocalStorage, - encoder: JSONEncoder = AuthClient.Configuration.jsonEncoder, - decoder: JSONDecoder = AuthClient.Configuration.jsonDecoder, - fetch: @escaping AuthClient.FetchHandler = { try await URLSession.shared.data(for: $0) } - ) { - self.init( - url: url, - headers: headers, - flowType: flowType, - localStorage: localStorage, - logger: nil, - encoder: encoder, - decoder: decoder, - fetch: fetch - ) - } -} - -@available(*, deprecated, message: "Use MFATotpEnrollParams or MFAPhoneEnrollParams instead.") -public typealias MFAEnrollParams = MFATotpEnrollParams - -extension AuthAdmin { - @available( - *, - deprecated, - message: "Use deleteUser with UUID instead of string." - ) - public func deleteUser(id: String, shouldSoftDelete: Bool = false) async throws { - guard let id = UUID(uuidString: id) else { - fatalError("id should be a valid UUID") - } - - try await self.deleteUser(id: id, shouldSoftDelete: shouldSoftDelete) - } -} diff --git a/Sources/Auth/Types.swift b/Sources/Auth/Types.swift index f0400d4e8..1248d0e0c 100644 --- a/Sources/Auth/Types.swift +++ b/Sources/Auth/Types.swift @@ -11,31 +11,12 @@ public enum AuthChangeEvent: String, Sendable { case mfaChallengeVerified = "MFA_CHALLENGE_VERIFIED" } -@available( - *, - deprecated, - message: "Access to UserCredentials will be removed on the next major release." -) -public struct UserCredentials: Codable, Hashable, Sendable { - public var email: String? - public var password: String? - public var phone: String? - public var refreshToken: String? - public var gotrueMetaSecurity: AuthMetaSecurity? - - public init( - email: String? = nil, - password: String? = nil, - phone: String? = nil, - refreshToken: String? = nil, - gotrueMetaSecurity: AuthMetaSecurity? = nil - ) { - self.email = email - self.password = password - self.phone = phone - self.refreshToken = refreshToken - self.gotrueMetaSecurity = gotrueMetaSecurity - } +struct UserCredentials: Codable, Hashable, Sendable { + var email: String? + var password: String? + var phone: String? + var refreshToken: String? + var gotrueMetaSecurity: AuthMetaSecurity? } struct SignUpRequest: Codable, Hashable, Sendable { @@ -479,9 +460,6 @@ public struct UserAttributes: Codable, Hashable, Sendable { /// Note: Call ``AuthClient/reauthenticate()`` to obtain the nonce first. public var nonce: String? - /// An email change token. - @available(*, deprecated, message: "This is an old field, stop relying on it.") - public var emailChangeToken: String? /// A custom data object to store the user's metadata. This maps to the `auth.users.user_metadata` /// column. The `data` should be a JSON object that includes user-specific info, such as their /// first and last name. @@ -495,14 +473,12 @@ public struct UserAttributes: Codable, Hashable, Sendable { phone: String? = nil, password: String? = nil, nonce: String? = nil, - emailChangeToken: String? = nil, data: [String: AnyJSON]? = nil ) { self.email = email self.phone = phone self.password = password self.nonce = nonce - self.emailChangeToken = emailChangeToken self.data = data } } diff --git a/Tests/AuthTests/AuthClientTests.swift b/Tests/AuthTests/AuthClientTests.swift index 48e7a5d84..9711569f7 100644 --- a/Tests/AuthTests/AuthClientTests.swift +++ b/Tests/AuthTests/AuthClientTests.swift @@ -1246,12 +1246,12 @@ final class AuthClientTests: XCTestCase { curl \ --request PUT \ --header "Authorization: Bearer accesstoken" \ - --header "Content-Length: 258" \ + --header "Content-Length: 228" \ --header "Content-Type: application/json" \ --header "X-Client-Info: auth-swift/0.0.0" \ --header "X-Supabase-Api-Version: 2024-01-01" \ --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ - --data "{\"code_challenge\":\"hgJeigklONUI1pKSS98MIAbtJGaNu0zJU1iSiFOn2lY\",\"code_challenge_method\":\"s256\",\"data\":{\"custom_key\":\"custom_value\"},\"email\":\"example@mail.com\",\"email_change_token\":\"123456\",\"nonce\":\"abcdef\",\"password\":\"another.pass\",\"phone\":\"+1 202-918-2132\"}" \ + --data "{\"code_challenge\":\"hgJeigklONUI1pKSS98MIAbtJGaNu0zJU1iSiFOn2lY\",\"code_challenge_method\":\"s256\",\"data\":{\"custom_key\":\"custom_value\"},\"email\":\"example@mail.com\",\"nonce\":\"abcdef\",\"password\":\"another.pass\",\"phone\":\"+1 202-918-2132\"}" \ "http://localhost:54321/auth/v1/user" """# } @@ -1267,7 +1267,6 @@ final class AuthClientTests: XCTestCase { phone: "+1 202-918-2132", password: "another.pass", nonce: "abcdef", - emailChangeToken: "123456", data: ["custom_key": .string("custom_value")] ) ) @@ -1560,7 +1559,7 @@ final class AuthClientTests: XCTestCase { Dependencies[sut.clientID].sessionStorage.store(.validSession) let response = try await sut.mfa.enroll( - params: MFAEnrollParams( + params: .totp( issuer: "supabase.com", friendlyName: "test" ) @@ -2660,6 +2659,7 @@ final class AuthClientTests: XCTestCase { /// - action: The async action to perform that should trigger events /// - expectedEvents: Array of expected AuthChangeEvent values /// - expectedSessions: Array of expected Session values (optional) + @discardableResult private func assertAuthStateChanges( sut: AuthClient, action: () async throws -> T, diff --git a/Tests/AuthTests/RequestsTests.swift b/Tests/AuthTests/RequestsTests.swift index a8dcde072..eaae7e42b 100644 --- a/Tests/AuthTests/RequestsTests.swift +++ b/Tests/AuthTests/RequestsTests.swift @@ -291,7 +291,6 @@ final class RequestsTests: XCTestCase { phone: "+1 202-918-2132", password: "another.pass", nonce: "abcdef", - emailChangeToken: "123456", data: ["custom_key": .string("custom_value")] ) ) @@ -431,7 +430,7 @@ final class RequestsTests: XCTestCase { await assert { _ = try await sut.mfa.enroll( - params: MFAEnrollParams(issuer: "supabase.com", friendlyName: "test")) + params: .totp(issuer: "supabase.com", friendlyName: "test")) } } diff --git a/Tests/AuthTests/__Snapshots__/RequestsTests/testUpdateUser.1.txt b/Tests/AuthTests/__Snapshots__/RequestsTests/testUpdateUser.1.txt index 45eaa5f0f..7efb07af0 100644 --- a/Tests/AuthTests/__Snapshots__/RequestsTests/testUpdateUser.1.txt +++ b/Tests/AuthTests/__Snapshots__/RequestsTests/testUpdateUser.1.txt @@ -5,5 +5,5 @@ curl \ --header "Content-Type: application/json" \ --header "X-Client-Info: gotrue-swift/x.y.z" \ --header "X-Supabase-Api-Version: 2024-01-01" \ - --data "{\"data\":{\"custom_key\":\"custom_value\"},\"email\":\"example@mail.com\",\"email_change_token\":\"123456\",\"nonce\":\"abcdef\",\"password\":\"another.pass\",\"phone\":\"+1 202-918-2132\"}" \ + --data "{\"data\":{\"custom_key\":\"custom_value\"},\"email\":\"example@mail.com\",\"nonce\":\"abcdef\",\"password\":\"another.pass\",\"phone\":\"+1 202-918-2132\"}" \ "http://localhost:54321/auth/v1/user" \ No newline at end of file