Skip to content

Commit 9cfafdb

Browse files
bryandubnogrdsdev
andauthored
feat: expose Realtime options on SupabaseClient (supabase#377)
* Update RealtimeClientV2.swift Provide a means to configure RealtimeV2 options * Update SupabaseClient.swift * Update RealtimeClientV2.swift * feat(realtime): add RealtimeClientOptions and expose it to SupbaseClient * Add deprecated init to avoid breaking changes * use renamed for deprecation message * merge headers in-place * fix realtime integration tests * test logger instance --------- Co-authored-by: Guilherme Souza <[email protected]>
1 parent 866a039 commit 9cfafdb

File tree

12 files changed

+210
-80
lines changed

12 files changed

+210
-80
lines changed

Package.swift

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,11 +158,18 @@ let package = Package(
158158
"Functions",
159159
]
160160
),
161-
.testTarget(name: "SupabaseTests", dependencies: ["Supabase"]),
161+
.testTarget(
162+
name: "SupabaseTests",
163+
dependencies: [
164+
"Supabase",
165+
.product(name: "CustomDump", package: "swift-custom-dump"),
166+
]
167+
),
162168
.target(
163169
name: "TestHelpers",
164170
dependencies: [
165171
.product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"),
172+
.product(name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay"),
166173
"Auth",
167174
]
168175
),

Sources/Realtime/V2/RealtimeChannelV2.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ public actor RealtimeChannelV2 {
7070
/// Subscribes to the channel
7171
public func subscribe() async {
7272
if await socket?.status != .connected {
73-
if socket?.config.connectOnSubscribe != true {
73+
if socket?.options.connectOnSubscribe != true {
7474
fatalError(
7575
"You can't subscribe to a channel while the realtime client is not connected. Did you forget to call `realtime.connect()`?"
7676
)

Sources/Realtime/V2/RealtimeClientV2.swift

Lines changed: 73 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import Foundation
1616
public typealias JSONObject = _Helpers.JSONObject
1717

1818
public actor RealtimeClientV2 {
19+
@available(*, deprecated, renamed: "RealtimeClientOptions")
1920
public struct Configuration: Sendable {
2021
var url: URL
2122
var apiKey: String
@@ -64,10 +65,12 @@ public actor RealtimeClientV2 {
6465
}
6566
}
6667

67-
let config: Configuration
68+
let url: URL
69+
let options: RealtimeClientOptions
6870
let ws: any WebSocketClient
6971

7072
var accessToken: String?
73+
let apikey: String?
7174
var ref = 0
7275
var pendingHeartbeatRef: Int?
7376

@@ -79,34 +82,66 @@ public actor RealtimeClientV2 {
7982

8083
private let statusEventEmitter = EventEmitter<Status>(initialEvent: .disconnected)
8184

85+
/// AsyncStream that emits when connection status change.
86+
///
87+
/// You can also use ``onStatusChange(_:)`` for a closure based method.
8288
public var statusChange: AsyncStream<Status> {
8389
statusEventEmitter.stream()
8490
}
8591

92+
/// The current connection status.
8693
public private(set) var status: Status {
8794
get { statusEventEmitter.lastEvent.value }
8895
set { statusEventEmitter.emit(newValue) }
8996
}
9097

98+
/// Listen for connection status changes.
99+
/// - Parameter listener: Closure that will be called when connection status changes.
100+
/// - Returns: An observation handle that can be used to stop listening.
101+
///
102+
/// - Note: Use ``statusChange`` if you prefer to use Async/Await.
91103
public func onStatusChange(
92104
_ listener: @escaping @Sendable (Status) -> Void
93105
) -> ObservationToken {
94106
statusEventEmitter.attach(listener)
95107
}
96108

109+
@available(*, deprecated, renamed: "RealtimeClientV2.init(url:options:)")
97110
public init(config: Configuration) {
98-
self.init(config: config, ws: WebSocket(config: config))
111+
self.init(
112+
url: config.url,
113+
options: RealtimeClientOptions(
114+
headers: config.headers,
115+
heartbeatInterval: config.heartbeatInterval,
116+
reconnectDelay: config.reconnectDelay,
117+
timeoutInterval: config.timeoutInterval,
118+
disconnectOnSessionLoss: config.disconnectOnSessionLoss,
119+
connectOnSubscribe: config.connectOnSubscribe,
120+
logger: config.logger
121+
)
122+
)
99123
}
100124

101-
init(config: Configuration, ws: any WebSocketClient) {
102-
self.config = config
103-
self.ws = ws
125+
public init(url: URL, options: RealtimeClientOptions) {
126+
self.init(
127+
url: url,
128+
options: options,
129+
ws: WebSocket(
130+
realtimeURL: Self.realtimeWebSocketURL(
131+
baseURL: Self.realtimeBaseURL(url: url),
132+
apikey: options.apikey
133+
),
134+
options: options
135+
)
136+
)
137+
}
104138

105-
if let customJWT = config.headers["Authorization"]?.split(separator: " ").last {
106-
accessToken = String(customJWT)
107-
} else {
108-
accessToken = config.apiKey
109-
}
139+
init(url: URL, options: RealtimeClientOptions, ws: any WebSocketClient) {
140+
self.url = url
141+
self.options = options
142+
self.ws = ws
143+
accessToken = options.accessToken ?? options.apikey
144+
apikey = options.apikey
110145
}
111146

112147
deinit {
@@ -126,16 +161,16 @@ public actor RealtimeClientV2 {
126161
if status == .disconnected {
127162
connectionTask = Task {
128163
if reconnect {
129-
try? await Task.sleep(nanoseconds: NSEC_PER_SEC * UInt64(config.reconnectDelay))
164+
try? await Task.sleep(nanoseconds: NSEC_PER_SEC * UInt64(options.reconnectDelay))
130165

131166
if Task.isCancelled {
132-
config.logger?.debug("Reconnect cancelled, returning")
167+
options.logger?.debug("Reconnect cancelled, returning")
133168
return
134169
}
135170
}
136171

137172
if status == .connected {
138-
config.logger?.debug("WebsSocket already connected")
173+
options.logger?.debug("WebsSocket already connected")
139174
return
140175
}
141176

@@ -165,7 +200,7 @@ public actor RealtimeClientV2 {
165200

166201
private func onConnected(reconnect: Bool) async {
167202
status = .connected
168-
config.logger?.debug("Connected to realtime WebSocket")
203+
options.logger?.debug("Connected to realtime WebSocket")
169204
listenForMessages()
170205
startHeartbeating()
171206
if reconnect {
@@ -174,17 +209,17 @@ public actor RealtimeClientV2 {
174209
}
175210

176211
private func onDisconnected() async {
177-
config.logger?
212+
options.logger?
178213
.debug(
179-
"WebSocket disconnected. Trying again in \(config.reconnectDelay)"
214+
"WebSocket disconnected. Trying again in \(options.reconnectDelay)"
180215
)
181216
await reconnect()
182217
}
183218

184219
private func onError(_ error: (any Error)?) async {
185-
config.logger?
220+
options.logger?
186221
.debug(
187-
"WebSocket error \(error?.localizedDescription ?? "<none>"). Trying again in \(config.reconnectDelay)"
222+
"WebSocket error \(error?.localizedDescription ?? "<none>"). Trying again in \(options.reconnectDelay)"
188223
)
189224
await reconnect()
190225
}
@@ -208,7 +243,7 @@ public actor RealtimeClientV2 {
208243
topic: "realtime:\(topic)",
209244
config: config,
210245
socket: self,
211-
logger: self.config.logger
246+
logger: self.options.logger
212247
)
213248
}
214249

@@ -224,7 +259,7 @@ public actor RealtimeClientV2 {
224259
subscriptions[channel.topic] = nil
225260

226261
if subscriptions.isEmpty {
227-
config.logger?.debug("No more subscribed channel in socket")
262+
options.logger?.debug("No more subscribed channel in socket")
228263
disconnect()
229264
}
230265
}
@@ -254,18 +289,18 @@ public actor RealtimeClientV2 {
254289
await onMessage(message)
255290
}
256291
} catch {
257-
config.logger?.debug(
258-
"Error while listening for messages. Trying again in \(config.reconnectDelay) \(error)"
292+
options.logger?.debug(
293+
"Error while listening for messages. Trying again in \(options.reconnectDelay) \(error)"
259294
)
260295
await reconnect()
261296
}
262297
}
263298
}
264299

265300
private func startHeartbeating() {
266-
heartbeatTask = Task { [weak self, config] in
301+
heartbeatTask = Task { [weak self, options] in
267302
while !Task.isCancelled {
268-
try? await Task.sleep(nanoseconds: NSEC_PER_SEC * UInt64(config.heartbeatInterval))
303+
try? await Task.sleep(nanoseconds: NSEC_PER_SEC * UInt64(options.heartbeatInterval))
269304
if Task.isCancelled {
270305
break
271306
}
@@ -277,7 +312,7 @@ public actor RealtimeClientV2 {
277312
private func sendHeartbeat() async {
278313
if pendingHeartbeatRef != nil {
279314
pendingHeartbeatRef = nil
280-
config.logger?.debug("Heartbeat timeout")
315+
options.logger?.debug("Heartbeat timeout")
281316

282317
await reconnect()
283318
return
@@ -297,7 +332,7 @@ public actor RealtimeClientV2 {
297332
}
298333

299334
public func disconnect() {
300-
config.logger?.debug("Closing WebSocket connection")
335+
options.logger?.debug("Closing WebSocket connection")
301336
ref = 0
302337
messageTask?.cancel()
303338
heartbeatTask?.cancel()
@@ -323,9 +358,9 @@ public actor RealtimeClientV2 {
323358

324359
if let ref = message.ref, Int(ref) == pendingHeartbeatRef {
325360
pendingHeartbeatRef = nil
326-
config.logger?.debug("heartbeat received")
361+
options.logger?.debug("heartbeat received")
327362
} else {
328-
config.logger?
363+
options.logger?
329364
.debug("Received event \(message.event) for channel \(channel?.topic ?? "null")")
330365
await channel?.onMessage(message)
331366
}
@@ -335,14 +370,14 @@ public actor RealtimeClientV2 {
335370
/// - Parameter message: The message to push through the socket.
336371
public func push(_ message: RealtimeMessageV2) async {
337372
guard status == .connected else {
338-
config.logger?.warning("Trying to push a message while socket is not connected. This is not supported yet.")
373+
options.logger?.warning("Trying to push a message while socket is not connected. This is not supported yet.")
339374
return
340375
}
341376

342377
do {
343378
try await ws.send(message)
344379
} catch {
345-
config.logger?.debug("""
380+
options.logger?.debug("""
346381
Failed to send message:
347382
\(message)
348383
@@ -356,10 +391,8 @@ public actor RealtimeClientV2 {
356391
ref += 1
357392
return ref
358393
}
359-
}
360394

361-
extension RealtimeClientV2.Configuration {
362-
var realtimeBaseURL: URL {
395+
static func realtimeBaseURL(url: URL) -> URL {
363396
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
364397
return url
365398
}
@@ -377,21 +410,23 @@ extension RealtimeClientV2.Configuration {
377410
return url
378411
}
379412

380-
var realtimeWebSocketURL: URL {
381-
guard var components = URLComponents(url: realtimeBaseURL, resolvingAgainstBaseURL: false)
413+
static func realtimeWebSocketURL(baseURL: URL, apikey: String?) -> URL {
414+
guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)
382415
else {
383-
return realtimeBaseURL
416+
return baseURL
384417
}
385418

386419
components.queryItems = components.queryItems ?? []
387-
components.queryItems!.append(URLQueryItem(name: "apikey", value: apiKey))
420+
if let apikey {
421+
components.queryItems!.append(URLQueryItem(name: "apikey", value: apikey))
422+
}
388423
components.queryItems!.append(URLQueryItem(name: "vsn", value: "1.0.0"))
389424

390425
components.path.append("/websocket")
391426
components.path = components.path.replacingOccurrences(of: "//", with: "/")
392427

393428
guard let url = components.url else {
394-
return realtimeBaseURL
429+
return baseURL
395430
}
396431

397432
return url

Sources/Realtime/V2/Types.swift

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
//
2+
// Types.swift
3+
//
4+
//
5+
// Created by Guilherme Souza on 13/05/24.
6+
//
7+
8+
import _Helpers
9+
import Foundation
10+
11+
/// Options for initializing ``RealtimeClientV2``.
12+
public struct RealtimeClientOptions: Sendable {
13+
package var headers: HTTPHeaders
14+
var heartbeatInterval: TimeInterval
15+
var reconnectDelay: TimeInterval
16+
var timeoutInterval: TimeInterval
17+
var disconnectOnSessionLoss: Bool
18+
var connectOnSubscribe: Bool
19+
package var logger: (any SupabaseLogger)?
20+
21+
public static let defaultHeartbeatInterval: TimeInterval = 15
22+
public static let defaultReconnectDelay: TimeInterval = 7
23+
public static let defaultTimeoutInterval: TimeInterval = 10
24+
public static let defaultDisconnectOnSessionLoss = true
25+
public static let defaultConnectOnSubscribe: Bool = true
26+
27+
public init(
28+
headers: [String: String] = [:],
29+
heartbeatInterval: TimeInterval = Self.defaultHeartbeatInterval,
30+
reconnectDelay: TimeInterval = Self.defaultReconnectDelay,
31+
timeoutInterval: TimeInterval = Self.defaultTimeoutInterval,
32+
disconnectOnSessionLoss: Bool = Self.defaultDisconnectOnSessionLoss,
33+
connectOnSubscribe: Bool = Self.defaultConnectOnSubscribe,
34+
logger: (any SupabaseLogger)? = nil
35+
) {
36+
self.headers = HTTPHeaders(headers)
37+
self.heartbeatInterval = heartbeatInterval
38+
self.reconnectDelay = reconnectDelay
39+
self.timeoutInterval = timeoutInterval
40+
self.disconnectOnSessionLoss = disconnectOnSessionLoss
41+
self.connectOnSubscribe = connectOnSubscribe
42+
self.logger = logger
43+
}
44+
45+
var apikey: String? {
46+
headers["apikey"]
47+
}
48+
49+
var accessToken: String? {
50+
guard let accessToken = headers["Authorization"]?.split(separator: " ").last else {
51+
return nil
52+
}
53+
return String(accessToken)
54+
}
55+
}

Sources/Realtime/V2/WebSocketClient.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,13 @@ final class WebSocket: NSObject, URLSessionWebSocketDelegate, WebSocketClient, @
4444

4545
private let mutableState = LockIsolated(MutableState())
4646

47-
init(config: RealtimeClientV2.Configuration) {
48-
realtimeURL = config.realtimeWebSocketURL
47+
init(realtimeURL: URL, options: RealtimeClientOptions) {
48+
self.realtimeURL = realtimeURL
4949

5050
let sessionConfiguration = URLSessionConfiguration.default
51-
sessionConfiguration.httpAdditionalHeaders = config.headers
51+
sessionConfiguration.httpAdditionalHeaders = options.headers.dictionary
5252
configuration = sessionConfiguration
53-
logger = config.logger
53+
logger = options.logger
5454
}
5555

5656
func connect() -> AsyncStream<ConnectionStatus> {

0 commit comments

Comments
 (0)