@@ -32,7 +32,7 @@ public final class RealtimeClientV2: Sendable {
32
32
var messageTask : Task < Void , Never > ?
33
33
34
34
var connectionTask : Task < Void , Never > ?
35
- var channels : [ RealtimeChannelV2 ] = [ ]
35
+ var channels : [ String : RealtimeChannelV2 ] = [ : ]
36
36
var sendBuffer : [ @Sendable ( ) -> Void ] = [ ]
37
37
38
38
var conn : ( any WebSocket ) ?
@@ -51,13 +51,11 @@ public final class RealtimeClientV2: Sendable {
51
51
52
52
/// All managed channels indexed by their topics.
53
53
public var channels : [ String : RealtimeChannelV2 ] {
54
- mutableState. channels. reduce (
55
- into: [ : ] ,
56
- { $0 [ $1. topic] = $1 }
57
- )
54
+ mutableState. channels
58
55
}
59
56
60
57
private let statusSubject = AsyncValueSubject < RealtimeClientStatus > ( . disconnected)
58
+ private let heartbeatSubject = AsyncValueSubject < HeartbeatStatus ? > ( nil )
61
59
62
60
/// Listen for connection status changes.
63
61
///
@@ -72,6 +70,16 @@ public final class RealtimeClientV2: Sendable {
72
70
set { statusSubject. yield ( newValue) }
73
71
}
74
72
73
+ /// Listen for heartbeat status.
74
+ ///
75
+ /// You can also use ``onHeartbeat(_:)`` for a closure based method.
76
+ public var heartbeat : AsyncStream < HeartbeatStatus > {
77
+ AsyncStream (
78
+ heartbeatSubject. values. compactMap { $0 }
79
+ as AsyncCompactMapSequence < AsyncStream < HeartbeatStatus ? > , HeartbeatStatus >
80
+ )
81
+ }
82
+
75
83
/// Listen for connection status changes.
76
84
/// - Parameter listener: Closure that will be called when connection status changes.
77
85
/// - Returns: An observation handle that can be used to stop listening.
@@ -84,6 +92,21 @@ public final class RealtimeClientV2: Sendable {
84
92
return RealtimeSubscription { task. cancel ( ) }
85
93
}
86
94
95
+ /// Listen for heatbeat checks.
96
+ /// - Parameter listener: Closure that will be called when heartbeat status changes.
97
+ /// - Returns: An observation handle that can be used to stop listening.
98
+ ///
99
+ /// - Note: Use ``heartbeat`` if you prefer to use Async/Await.
100
+ public func onHeartbeat(
101
+ _ listener: @escaping @Sendable ( HeartbeatStatus ) -> Void
102
+ ) -> RealtimeSubscription {
103
+ let task = heartbeatSubject. onChange { message in
104
+ guard let message else { return }
105
+ listener ( message)
106
+ }
107
+ return RealtimeSubscription { task. cancel ( ) }
108
+ }
109
+
87
110
public convenience init ( url: URL , options: RealtimeClientOptions ) {
88
111
var interceptors : [ any HTTPClientInterceptor ] = [ ]
89
112
@@ -139,7 +162,7 @@ public final class RealtimeClientV2: Sendable {
139
162
mutableState. withValue {
140
163
$0. heartbeatTask? . cancel ( )
141
164
$0. messageTask? . cancel ( )
142
- $0. channels = [ ]
165
+ $0. channels = [ : ]
143
166
}
144
167
}
145
168
@@ -223,14 +246,15 @@ public final class RealtimeClientV2: Sendable {
223
246
224
247
private func onClose( code: Int ? , reason: String ? ) {
225
248
options. logger? . debug (
226
- " WebSocket closed. Code: \( code? . description ?? " <none> " ) , Reason: \( reason ?? " <none> " ) " )
249
+ " WebSocket closed. Code: \( code? . description ?? " <none> " ) , Reason: \( reason ?? " <none> " ) "
250
+ )
227
251
228
252
reconnect ( )
229
253
}
230
254
231
- private func reconnect( ) {
255
+ private func reconnect( disconnectReason : String ? = nil ) {
232
256
Task {
233
- disconnect ( )
257
+ disconnect ( reason : disconnectReason )
234
258
await connect ( reconnect: true )
235
259
}
236
260
}
@@ -246,35 +270,42 @@ public final class RealtimeClientV2: Sendable {
246
270
_ topic: String ,
247
271
options: @Sendable ( inout RealtimeChannelConfig ) -> Void = { _ in }
248
272
) -> RealtimeChannelV2 {
249
- var config = RealtimeChannelConfig (
250
- broadcast: BroadcastJoinConfig ( acknowledgeBroadcasts: false , receiveOwnBroadcasts: false ) ,
251
- presence: PresenceJoinConfig ( key: " " ) ,
252
- isPrivate: false
253
- )
254
- options ( & config)
273
+ mutableState. withValue {
274
+ let realtimeTopic = " realtime: \( topic) "
255
275
256
- let channel = RealtimeChannelV2 (
257
- topic: " realtime: \( topic) " ,
258
- config: config,
259
- socket: self ,
260
- logger: self . options. logger
261
- )
276
+ if let channel = $0. channels [ realtimeTopic] {
277
+ return channel
278
+ }
262
279
263
- mutableState. withValue {
264
- $0. channels. append ( channel)
265
- }
280
+ var config = RealtimeChannelConfig (
281
+ broadcast: BroadcastJoinConfig ( acknowledgeBroadcasts: false , receiveOwnBroadcasts: false ) ,
282
+ presence: PresenceJoinConfig ( key: " " ) ,
283
+ isPrivate: false
284
+ )
285
+ options ( & config)
266
286
267
- return channel
287
+ let channel = RealtimeChannelV2 (
288
+ topic: realtimeTopic,
289
+ config: config,
290
+ socket: self ,
291
+ logger: self . options. logger
292
+ )
293
+
294
+ $0. channels [ realtimeTopic] = channel
295
+
296
+ return channel
297
+ }
268
298
}
269
299
270
300
@available (
271
- * , deprecated,
301
+ * ,
302
+ deprecated,
272
303
message:
273
304
" Client handles channels automatically, this method will be removed on the next major release. "
274
305
)
275
306
public func addChannel( _ channel: RealtimeChannelV2 ) {
276
307
mutableState. withValue {
277
- $0. channels. append ( channel)
308
+ $0. channels [ channel . topic ] = channel
278
309
}
279
310
}
280
311
@@ -294,9 +325,7 @@ public final class RealtimeClientV2: Sendable {
294
325
295
326
func _remove( _ channel: RealtimeChannelV2 ) {
296
327
mutableState. withValue {
297
- $0. channels. removeAll {
298
- $0. joinRef == channel. joinRef
299
- }
328
+ $0. channels [ channel. topic] = nil
300
329
}
301
330
}
302
331
@@ -372,6 +401,11 @@ public final class RealtimeClientV2: Sendable {
372
401
}
373
402
374
403
private func sendHeartbeat( ) async {
404
+ if status != . connected {
405
+ heartbeatSubject. yield ( . disconnected)
406
+ return
407
+ }
408
+
375
409
let pendingHeartbeatRef : String ? = mutableState. withValue {
376
410
if $0. pendingHeartbeatRef != nil {
377
411
$0. pendingHeartbeatRef = nil
@@ -393,10 +427,12 @@ public final class RealtimeClientV2: Sendable {
393
427
payload: [ : ]
394
428
)
395
429
)
430
+ heartbeatSubject. yield ( . sent)
396
431
await setAuth ( )
397
432
} else {
398
433
options. logger? . debug ( " Heartbeat timeout " )
399
- reconnect ( )
434
+ heartbeatSubject. yield ( . timeout)
435
+ reconnect ( disconnectReason: " heartbeat timeout " )
400
436
}
401
437
}
402
438
@@ -453,7 +489,11 @@ public final class RealtimeClientV2: Sendable {
453
489
}
454
490
455
491
private func onMessage( _ message: RealtimeMessageV2 ) async {
456
- let channels = mutableState. withValue {
492
+ if message. topic == " phoenix " , message. event == " phx_reply " {
493
+ heartbeatSubject. yield ( message. status == . ok ? . ok : . error)
494
+ }
495
+
496
+ let channel = mutableState. withValue {
457
497
if let ref = message. ref, ref == $0. pendingHeartbeatRef {
458
498
$0. pendingHeartbeatRef = nil
459
499
options. logger? . debug ( " heartbeat received " )
@@ -462,10 +502,10 @@ public final class RealtimeClientV2: Sendable {
462
502
. debug ( " Received event \( message. event) for channel \( message. topic) " )
463
503
}
464
504
465
- return $0. channels. filter { $0 . topic == message. topic }
505
+ return $0. channels [ message. topic]
466
506
}
467
507
468
- for channel in channels {
508
+ if let channel {
469
509
await channel. onMessage ( message)
470
510
}
471
511
}
@@ -488,7 +528,8 @@ public final class RealtimeClientV2: Sendable {
488
528
489
529
Error:
490
530
\( error)
491
- """ )
531
+ """
532
+ )
492
533
}
493
534
}
494
535
0 commit comments