Skip to content

Commit bc0d779

Browse files
Guilherme Souzaclaude
andauthored
feat(realtime): add explicit REST API broadcast method (#818)
* fix(realtime): add explicit REST API broadcast method This commit ports the feature from supabase-js PR #1749 which adds an explicit `postSend()` method for sending broadcast messages via REST API, addressing the issue where users may unknowingly use REST fallback when WebSocket is not connected. Changes: - Add `postSend()` method to RealtimeChannelV2 for explicit REST delivery - Add deprecation warning to `broadcast()` when falling back to REST - Add comprehensive test coverage for the new method - Support custom timeout parameter for REST requests - Include proper error handling and status code validation The `postSend()` method always uses the REST API endpoint regardless of WebSocket connection state, making it clear to developers when they are using REST vs WebSocket delivery. Ref: supabase/supabase-js#1749 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * refactor: rename postSend to httpSend Rename the explicit REST API broadcast method from postSend to httpSend to better align with naming conventions. Changes: - Rename postSend() to httpSend() in RealtimeChannelV2 - Update all test names from testPostSend to testHttpSend - Update deprecation warning to reference httpSend() - Update error messages to reference httpSend() All tests continue to pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * drop MainActor * simplify try/catch * reuse BroadcastMessagePayload --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 21425be commit bc0d779

File tree

3 files changed

+365
-16
lines changed

3 files changed

+365
-16
lines changed

Sources/Realtime/RealtimeChannelV2.swift

Lines changed: 94 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,9 @@ public final class RealtimeChannelV2: Sendable, RealtimeChannelProtocol {
9393

9494
/// Subscribes to the channel.
9595
public func subscribeWithError() async throws {
96-
logger?.debug("Starting subscription to channel '\(topic)' (attempt 1/\(socket.options.maxRetryAttempts))")
96+
logger?.debug(
97+
"Starting subscription to channel '\(topic)' (attempt 1/\(socket.options.maxRetryAttempts))"
98+
)
9799

98100
status = .subscribing
99101

@@ -248,6 +250,87 @@ public final class RealtimeChannelV2: Sendable, RealtimeChannelProtocol {
248250
)
249251
}
250252

253+
/// Sends a broadcast message explicitly via REST API.
254+
///
255+
/// This method always uses the REST API endpoint regardless of WebSocket connection state.
256+
/// Useful when you want to guarantee REST delivery or when gradually migrating from implicit REST fallback.
257+
///
258+
/// - Parameters:
259+
/// - event: The name of the broadcast event.
260+
/// - message: Message payload (required).
261+
/// - timeout: Optional timeout interval. If not specified, uses the socket's default timeout.
262+
/// - Returns: `true` if the message was accepted (HTTP 202), otherwise throws an error.
263+
/// - Throws: An error if the access token is missing, payload is missing, or the request fails.
264+
public func httpSend(
265+
event: String,
266+
message: some Codable,
267+
timeout: TimeInterval? = nil
268+
) async throws {
269+
try await httpSend(event: event, message: JSONObject(message), timeout: timeout)
270+
}
271+
272+
/// Sends a broadcast message explicitly via REST API.
273+
///
274+
/// This method always uses the REST API endpoint regardless of WebSocket connection state.
275+
/// Useful when you want to guarantee REST delivery or when gradually migrating from implicit REST fallback.
276+
///
277+
/// - Parameters:
278+
/// - event: The name of the broadcast event.
279+
/// - message: Message payload as a `JSONObject` (required).
280+
/// - timeout: Optional timeout interval. If not specified, uses the socket's default timeout.
281+
/// - Returns: `true` if the message was accepted (HTTP 202), otherwise throws an error.
282+
/// - Throws: An error if the access token is missing, payload is missing, or the request fails.
283+
public func httpSend(
284+
event: String,
285+
message: JSONObject,
286+
timeout: TimeInterval? = nil
287+
) async throws {
288+
guard let accessToken = await socket._getAccessToken() else {
289+
throw RealtimeError("Access token is required for httpSend()")
290+
}
291+
292+
var headers: HTTPFields = [.contentType: "application/json"]
293+
if let apiKey = socket.options.apikey {
294+
headers[.apiKey] = apiKey
295+
}
296+
headers[.authorization] = "Bearer \(accessToken)"
297+
298+
let body = try await JSONEncoder().encode(
299+
BroadcastMessagePayload(
300+
messages: [
301+
BroadcastMessagePayload.Message(
302+
topic: topic,
303+
event: event,
304+
payload: message,
305+
private: config.isPrivate
306+
)
307+
]
308+
)
309+
)
310+
311+
let request = HTTPRequest(
312+
url: socket.broadcastURL,
313+
method: .post,
314+
headers: headers,
315+
body: body
316+
)
317+
318+
let response = try await withTimeout(interval: timeout ?? socket.options.timeoutInterval) { [self] in
319+
await Result {
320+
try await socket.http.send(request)
321+
}
322+
}.get()
323+
324+
guard response.statusCode == 202 else {
325+
// Try to parse error message from response body
326+
var errorMessage = HTTPURLResponse.localizedString(forStatusCode: response.statusCode)
327+
if let errorBody = try? response.decoded(as: [String: String].self) {
328+
errorMessage = errorBody["error"] ?? errorBody["message"] ?? errorMessage
329+
}
330+
throw RealtimeError(errorMessage)
331+
}
332+
}
333+
251334
/// Send a broadcast message with `event` and a `Codable` payload.
252335
/// - Parameters:
253336
/// - event: Broadcast message event.
@@ -263,6 +346,12 @@ public final class RealtimeChannelV2: Sendable, RealtimeChannelProtocol {
263346
@MainActor
264347
public func broadcast(event: String, message: JSONObject) async {
265348
if status != .subscribed {
349+
logger?.warning(
350+
"Realtime broadcast() is automatically falling back to REST API. "
351+
+ "This behavior will be deprecated in the future. "
352+
+ "Please use httpSend() explicitly for REST delivery."
353+
)
354+
266355
var headers: HTTPFields = [.contentType: "application/json"]
267356
if let apiKey = socket.options.apikey {
268357
headers[.apiKey] = apiKey
@@ -271,17 +360,6 @@ public final class RealtimeChannelV2: Sendable, RealtimeChannelProtocol {
271360
headers[.authorization] = "Bearer \(accessToken)"
272361
}
273362

274-
struct BroadcastMessagePayload: Encodable {
275-
let messages: [Message]
276-
277-
struct Message: Encodable {
278-
let topic: String
279-
let event: String
280-
let payload: JSONObject
281-
let `private`: Bool
282-
}
283-
}
284-
285363
let task = Task { [headers] in
286364
_ = try? await socket.http.send(
287365
HTTPRequest(
@@ -543,7 +621,7 @@ public final class RealtimeChannelV2: Sendable, RealtimeChannelProtocol {
543621
table: table,
544622
filter: filter
545623
) {
546-
guard case let .insert(action) = $0 else { return }
624+
guard case .insert(let action) = $0 else { return }
547625
callback(action)
548626
}
549627
}
@@ -562,7 +640,7 @@ public final class RealtimeChannelV2: Sendable, RealtimeChannelProtocol {
562640
table: table,
563641
filter: filter
564642
) {
565-
guard case let .update(action) = $0 else { return }
643+
guard case .update(let action) = $0 else { return }
566644
callback(action)
567645
}
568646
}
@@ -581,7 +659,7 @@ public final class RealtimeChannelV2: Sendable, RealtimeChannelProtocol {
581659
table: table,
582660
filter: filter
583661
) {
584-
guard case let .delete(action) = $0 else { return }
662+
guard case .delete(let action) = $0 else { return }
585663
callback(action)
586664
}
587665
}
@@ -673,3 +751,4 @@ public final class RealtimeChannelV2: Sendable, RealtimeChannelProtocol {
673751
push?.didReceive(status: PushStatus(rawValue: status) ?? .ok)
674752
}
675753
}
754+

Sources/Realtime/Types.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,3 +110,14 @@ extension HTTPField.Name {
110110
public enum LogLevel: String, Sendable {
111111
case info, warn, error
112112
}
113+
114+
struct BroadcastMessagePayload: Encodable {
115+
let messages: [Message]
116+
117+
struct Message: Encodable {
118+
let topic: String
119+
let event: String
120+
let payload: JSONObject
121+
let `private`: Bool
122+
}
123+
}

0 commit comments

Comments
 (0)