From 9ac9aad5798f84ac3f9bf13763639951f4b03b8a Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Wed, 8 Oct 2025 16:05:19 -0300 Subject: [PATCH 1/5] fix(realtime): add explicit REST API broadcast method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: https://github.com/supabase/supabase-js/pull/1749 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- Sources/Realtime/RealtimeChannelV2.swift | 107 +++++++ .../RealtimeTests/RealtimeChannelTests.swift | 268 +++++++++++++++++- 2 files changed, 374 insertions(+), 1 deletion(-) diff --git a/Sources/Realtime/RealtimeChannelV2.swift b/Sources/Realtime/RealtimeChannelV2.swift index bf0b3b467..64d0745c5 100644 --- a/Sources/Realtime/RealtimeChannelV2.swift +++ b/Sources/Realtime/RealtimeChannelV2.swift @@ -248,6 +248,107 @@ public final class RealtimeChannelV2: Sendable, RealtimeChannelProtocol { ) } + /// Sends a broadcast message explicitly via REST API. + /// + /// This method always uses the REST API endpoint regardless of WebSocket connection state. + /// Useful when you want to guarantee REST delivery or when gradually migrating from implicit REST fallback. + /// + /// - Parameters: + /// - event: The name of the broadcast event. + /// - message: Message payload (required). + /// - timeout: Optional timeout interval. If not specified, uses the socket's default timeout. + /// - Returns: `true` if the message was accepted (HTTP 202), otherwise throws an error. + /// - Throws: An error if the access token is missing, payload is missing, or the request fails. + @MainActor + public func postSend( + event: String, + message: some Codable, + timeout: TimeInterval? = nil + ) async throws { + try await postSend(event: event, message: JSONObject(message), timeout: timeout) + } + + /// Sends a broadcast message explicitly via REST API. + /// + /// This method always uses the REST API endpoint regardless of WebSocket connection state. + /// Useful when you want to guarantee REST delivery or when gradually migrating from implicit REST fallback. + /// + /// - Parameters: + /// - event: The name of the broadcast event. + /// - message: Message payload as a `JSONObject` (required). + /// - timeout: Optional timeout interval. If not specified, uses the socket's default timeout. + /// - Returns: `true` if the message was accepted (HTTP 202), otherwise throws an error. + /// - Throws: An error if the access token is missing, payload is missing, or the request fails. + @MainActor + public func postSend( + event: String, + message: JSONObject, + timeout: TimeInterval? = nil + ) async throws { + guard let accessToken = await socket._getAccessToken() else { + throw RealtimeError("Access token is required for postSend()") + } + + var headers: HTTPFields = [.contentType: "application/json"] + if let apiKey = socket.options.apikey { + headers[.apiKey] = apiKey + } + headers[.authorization] = "Bearer \(accessToken)" + + struct BroadcastMessagePayload: Encodable { + let messages: [Message] + + struct Message: Encodable { + let topic: String + let event: String + let payload: JSONObject + let `private`: Bool + } + } + + let body = try JSONEncoder().encode( + BroadcastMessagePayload( + messages: [ + BroadcastMessagePayload.Message( + topic: topic, + event: event, + payload: message, + private: config.isPrivate + ) + ] + ) + ) + + let request = HTTPRequest( + url: socket.broadcastURL, + method: .post, + headers: headers, + body: body + ) + + let response: Helpers.HTTPResponse + do { + response = try await withTimeout(interval: timeout ?? socket.options.timeoutInterval) { [self] in + await Result { + try await socket.http.send(request) + } + }.get() + } catch is TimeoutError { + throw RealtimeError("Request timeout") + } catch { + throw error + } + + guard response.statusCode == 202 else { + // Try to parse error message from response body + var errorMessage = HTTPURLResponse.localizedString(forStatusCode: response.statusCode) + if let errorBody = try? JSONDecoder().decode([String: String].self, from: response.data) { + errorMessage = errorBody["error"] ?? errorBody["message"] ?? errorMessage + } + throw RealtimeError(errorMessage) + } + } + /// Send a broadcast message with `event` and a `Codable` payload. /// - Parameters: /// - event: Broadcast message event. @@ -263,6 +364,12 @@ public final class RealtimeChannelV2: Sendable, RealtimeChannelProtocol { @MainActor public func broadcast(event: String, message: JSONObject) async { if status != .subscribed { + logger?.warning( + "Realtime broadcast() is automatically falling back to REST API. " + + "This behavior will be deprecated in the future. " + + "Please use postSend() explicitly for REST delivery." + ) + var headers: HTTPFields = [.contentType: "application/json"] if let apiKey = socket.options.apikey { headers[.apiKey] = apiKey diff --git a/Tests/RealtimeTests/RealtimeChannelTests.swift b/Tests/RealtimeTests/RealtimeChannelTests.swift index 22e6e9504..d06e7f9dc 100644 --- a/Tests/RealtimeTests/RealtimeChannelTests.swift +++ b/Tests/RealtimeTests/RealtimeChannelTests.swift @@ -191,8 +191,274 @@ final class RealtimeChannelTests: XCTestCase { presenceSubscription.cancel() await channel.unsubscribe() socket.disconnect() - + // Note: We don't assert the subscribe status here because the test doesn't wait for completion // The subscription is still in progress when we clean up } + + @MainActor + func testPostSendThrowsWhenAccessTokenIsMissing() async { + let httpClient = await HTTPClientMock() + let (client, _) = FakeWebSocket.fakes() + + let socket = RealtimeClientV2( + url: URL(string: "https://localhost:54321/realtime/v1")!, + options: RealtimeClientOptions(headers: ["apikey": "test-key"]), + wsTransport: { _, _ in client }, + http: httpClient + ) + + let channel = socket.channel("test-topic") + + do { + try await channel.postSend(event: "test", message: ["data": "test"]) + XCTFail("Expected postSend to throw an error when access token is missing") + } catch { + XCTAssertEqual(error.localizedDescription, "Access token is required for postSend()") + } + } + + @MainActor + func testPostSendSucceedsOn202Status() async throws { + let httpClient = await HTTPClientMock() + await httpClient.when({ _ in true }) { _ in + HTTPResponse( + data: Data(), + response: HTTPURLResponse( + url: URL(string: "https://localhost:54321/api/broadcast")!, + statusCode: 202, + httpVersion: nil, + headerFields: nil + )! + ) + } + let (client, _) = FakeWebSocket.fakes() + + let socket = RealtimeClientV2( + url: URL(string: "https://localhost:54321/realtime/v1")!, + options: RealtimeClientOptions( + headers: ["apikey": "test-key"], + accessToken: { "test-token" } + ), + wsTransport: { _, _ in client }, + http: httpClient + ) + + let channel = socket.channel("test-topic") { config in + config.isPrivate = true + } + + try await channel.postSend(event: "test-event", message: ["data": "explicit"]) + + let requests = await httpClient.receivedRequests + XCTAssertEqual(requests.count, 1) + + let request = requests[0] + XCTAssertEqual(request.url.absoluteString, "https://localhost:54321/realtime/v1/api/broadcast") + XCTAssertEqual(request.method, .post) + XCTAssertEqual(request.headers[.authorization], "Bearer test-token") + XCTAssertEqual(request.headers[.apiKey], "test-key") + XCTAssertEqual(request.headers[.contentType], "application/json") + + let body = try JSONDecoder().decode(BroadcastPayload.self, from: request.body ?? Data()) + XCTAssertEqual(body.messages.count, 1) + XCTAssertEqual(body.messages[0].topic, "realtime:test-topic") + XCTAssertEqual(body.messages[0].event, "test-event") + XCTAssertEqual(body.messages[0].private, true) + } + + @MainActor + func testPostSendThrowsOnNon202Status() async { + let httpClient = await HTTPClientMock() + await httpClient.when({ _ in true }) { _ in + let errorBody = try JSONEncoder().encode(["error": "Server error"]) + return HTTPResponse( + data: errorBody, + response: HTTPURLResponse( + url: URL(string: "https://localhost:54321/api/broadcast")!, + statusCode: 500, + httpVersion: nil, + headerFields: nil + )! + ) + } + let (client, _) = FakeWebSocket.fakes() + + let socket = RealtimeClientV2( + url: URL(string: "https://localhost:54321/realtime/v1")!, + options: RealtimeClientOptions( + headers: ["apikey": "test-key"], + accessToken: { "test-token" } + ), + wsTransport: { _, _ in client }, + http: httpClient + ) + + let channel = socket.channel("test-topic") + + do { + try await channel.postSend(event: "test", message: ["data": "test"]) + XCTFail("Expected postSend to throw an error on non-202 status") + } catch { + XCTAssertEqual(error.localizedDescription, "Server error") + } + } + + @MainActor + func testPostSendRespectsCustomTimeout() async throws { + let httpClient = await HTTPClientMock() + await httpClient.when({ _ in true }) { _ in + HTTPResponse( + data: Data(), + response: HTTPURLResponse( + url: URL(string: "https://localhost:54321/api/broadcast")!, + statusCode: 202, + httpVersion: nil, + headerFields: nil + )! + ) + } + let (client, _) = FakeWebSocket.fakes() + + let socket = RealtimeClientV2( + url: URL(string: "https://localhost:54321/realtime/v1")!, + options: RealtimeClientOptions( + headers: ["apikey": "test-key"], + timeoutInterval: 5.0, + accessToken: { "test-token" } + ), + wsTransport: { _, _ in client }, + http: httpClient + ) + + let channel = socket.channel("test-topic") + + // Test with custom timeout + try await channel.postSend(event: "test", message: ["data": "test"], timeout: 3.0) + + let requests = await httpClient.receivedRequests + XCTAssertEqual(requests.count, 1) + } + + @MainActor + func testPostSendUsesDefaultTimeoutWhenNotSpecified() async throws { + let httpClient = await HTTPClientMock() + await httpClient.when({ _ in true }) { _ in + HTTPResponse( + data: Data(), + response: HTTPURLResponse( + url: URL(string: "https://localhost:54321/api/broadcast")!, + statusCode: 202, + httpVersion: nil, + headerFields: nil + )! + ) + } + let (client, _) = FakeWebSocket.fakes() + + let socket = RealtimeClientV2( + url: URL(string: "https://localhost:54321/realtime/v1")!, + options: RealtimeClientOptions( + headers: ["apikey": "test-key"], + timeoutInterval: 5.0, + accessToken: { "test-token" } + ), + wsTransport: { _, _ in client }, + http: httpClient + ) + + let channel = socket.channel("test-topic") + + // Test without custom timeout + try await channel.postSend(event: "test", message: ["data": "test"]) + + let requests = await httpClient.receivedRequests + XCTAssertEqual(requests.count, 1) + } + + @MainActor + func testPostSendFallsBackToStatusTextWhenErrorBodyHasNoErrorField() async { + let httpClient = await HTTPClientMock() + await httpClient.when({ _ in true }) { _ in + let errorBody = try JSONEncoder().encode(["message": "Invalid request"]) + return HTTPResponse( + data: errorBody, + response: HTTPURLResponse( + url: URL(string: "https://localhost:54321/api/broadcast")!, + statusCode: 400, + httpVersion: nil, + headerFields: nil + )! + ) + } + let (client, _) = FakeWebSocket.fakes() + + let socket = RealtimeClientV2( + url: URL(string: "https://localhost:54321/realtime/v1")!, + options: RealtimeClientOptions( + headers: ["apikey": "test-key"], + accessToken: { "test-token" } + ), + wsTransport: { _, _ in client }, + http: httpClient + ) + + let channel = socket.channel("test-topic") + + do { + try await channel.postSend(event: "test", message: ["data": "test"]) + XCTFail("Expected postSend to throw an error on 400 status") + } catch { + XCTAssertEqual(error.localizedDescription, "Invalid request") + } + } + + @MainActor + func testPostSendFallsBackToStatusTextWhenJSONParsingFails() async { + let httpClient = await HTTPClientMock() + await httpClient.when({ _ in true }) { _ in + HTTPResponse( + data: Data("Invalid JSON".utf8), + response: HTTPURLResponse( + url: URL(string: "https://localhost:54321/api/broadcast")!, + statusCode: 503, + httpVersion: nil, + headerFields: nil + )! + ) + } + let (client, _) = FakeWebSocket.fakes() + + let socket = RealtimeClientV2( + url: URL(string: "https://localhost:54321/realtime/v1")!, + options: RealtimeClientOptions( + headers: ["apikey": "test-key"], + accessToken: { "test-token" } + ), + wsTransport: { _, _ in client }, + http: httpClient + ) + + let channel = socket.channel("test-topic") + + do { + try await channel.postSend(event: "test", message: ["data": "test"]) + XCTFail("Expected postSend to throw an error on 503 status") + } catch { + // Should fall back to localized status text + XCTAssertTrue(error.localizedDescription.contains("503") || error.localizedDescription.contains("unavailable")) + } + } +} + +// Helper struct for decoding broadcast payload in tests +private struct BroadcastPayload: Decodable { + let messages: [Message] + + struct Message: Decodable { + let topic: String + let event: String + let payload: [String: String] + let `private`: Bool + } } From 514e8f10272ce62e3c1777254cd9042965b1021e Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Wed, 8 Oct 2025 16:10:14 -0300 Subject: [PATCH 2/5] refactor: rename postSend to httpSend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Sources/Realtime/RealtimeChannelV2.swift | 10 ++--- .../RealtimeTests/RealtimeChannelTests.swift | 38 +++++++++---------- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/Sources/Realtime/RealtimeChannelV2.swift b/Sources/Realtime/RealtimeChannelV2.swift index 64d0745c5..2067397dd 100644 --- a/Sources/Realtime/RealtimeChannelV2.swift +++ b/Sources/Realtime/RealtimeChannelV2.swift @@ -260,12 +260,12 @@ public final class RealtimeChannelV2: Sendable, RealtimeChannelProtocol { /// - Returns: `true` if the message was accepted (HTTP 202), otherwise throws an error. /// - Throws: An error if the access token is missing, payload is missing, or the request fails. @MainActor - public func postSend( + public func httpSend( event: String, message: some Codable, timeout: TimeInterval? = nil ) async throws { - try await postSend(event: event, message: JSONObject(message), timeout: timeout) + try await httpSend(event: event, message: JSONObject(message), timeout: timeout) } /// Sends a broadcast message explicitly via REST API. @@ -280,13 +280,13 @@ public final class RealtimeChannelV2: Sendable, RealtimeChannelProtocol { /// - Returns: `true` if the message was accepted (HTTP 202), otherwise throws an error. /// - Throws: An error if the access token is missing, payload is missing, or the request fails. @MainActor - public func postSend( + public func httpSend( event: String, message: JSONObject, timeout: TimeInterval? = nil ) async throws { guard let accessToken = await socket._getAccessToken() else { - throw RealtimeError("Access token is required for postSend()") + throw RealtimeError("Access token is required for httpSend()") } var headers: HTTPFields = [.contentType: "application/json"] @@ -367,7 +367,7 @@ public final class RealtimeChannelV2: Sendable, RealtimeChannelProtocol { logger?.warning( "Realtime broadcast() is automatically falling back to REST API. " + "This behavior will be deprecated in the future. " + - "Please use postSend() explicitly for REST delivery." + "Please use httpSend() explicitly for REST delivery." ) var headers: HTTPFields = [.contentType: "application/json"] diff --git a/Tests/RealtimeTests/RealtimeChannelTests.swift b/Tests/RealtimeTests/RealtimeChannelTests.swift index d06e7f9dc..03ebc6037 100644 --- a/Tests/RealtimeTests/RealtimeChannelTests.swift +++ b/Tests/RealtimeTests/RealtimeChannelTests.swift @@ -197,7 +197,7 @@ final class RealtimeChannelTests: XCTestCase { } @MainActor - func testPostSendThrowsWhenAccessTokenIsMissing() async { + func testHttpSendThrowsWhenAccessTokenIsMissing() async { let httpClient = await HTTPClientMock() let (client, _) = FakeWebSocket.fakes() @@ -211,15 +211,15 @@ final class RealtimeChannelTests: XCTestCase { let channel = socket.channel("test-topic") do { - try await channel.postSend(event: "test", message: ["data": "test"]) - XCTFail("Expected postSend to throw an error when access token is missing") + try await channel.httpSend(event: "test", message: ["data": "test"]) + XCTFail("Expected httpSend to throw an error when access token is missing") } catch { - XCTAssertEqual(error.localizedDescription, "Access token is required for postSend()") + XCTAssertEqual(error.localizedDescription, "Access token is required for httpSend()") } } @MainActor - func testPostSendSucceedsOn202Status() async throws { + func testHttpSendSucceedsOn202Status() async throws { let httpClient = await HTTPClientMock() await httpClient.when({ _ in true }) { _ in HTTPResponse( @@ -248,7 +248,7 @@ final class RealtimeChannelTests: XCTestCase { config.isPrivate = true } - try await channel.postSend(event: "test-event", message: ["data": "explicit"]) + try await channel.httpSend(event: "test-event", message: ["data": "explicit"]) let requests = await httpClient.receivedRequests XCTAssertEqual(requests.count, 1) @@ -268,7 +268,7 @@ final class RealtimeChannelTests: XCTestCase { } @MainActor - func testPostSendThrowsOnNon202Status() async { + func testHttpSendThrowsOnNon202Status() async { let httpClient = await HTTPClientMock() await httpClient.when({ _ in true }) { _ in let errorBody = try JSONEncoder().encode(["error": "Server error"]) @@ -297,15 +297,15 @@ final class RealtimeChannelTests: XCTestCase { let channel = socket.channel("test-topic") do { - try await channel.postSend(event: "test", message: ["data": "test"]) - XCTFail("Expected postSend to throw an error on non-202 status") + try await channel.httpSend(event: "test", message: ["data": "test"]) + XCTFail("Expected httpSend to throw an error on non-202 status") } catch { XCTAssertEqual(error.localizedDescription, "Server error") } } @MainActor - func testPostSendRespectsCustomTimeout() async throws { + func testHttpSendRespectsCustomTimeout() async throws { let httpClient = await HTTPClientMock() await httpClient.when({ _ in true }) { _ in HTTPResponse( @@ -334,14 +334,14 @@ final class RealtimeChannelTests: XCTestCase { let channel = socket.channel("test-topic") // Test with custom timeout - try await channel.postSend(event: "test", message: ["data": "test"], timeout: 3.0) + try await channel.httpSend(event: "test", message: ["data": "test"], timeout: 3.0) let requests = await httpClient.receivedRequests XCTAssertEqual(requests.count, 1) } @MainActor - func testPostSendUsesDefaultTimeoutWhenNotSpecified() async throws { + func testHttpSendUsesDefaultTimeoutWhenNotSpecified() async throws { let httpClient = await HTTPClientMock() await httpClient.when({ _ in true }) { _ in HTTPResponse( @@ -370,14 +370,14 @@ final class RealtimeChannelTests: XCTestCase { let channel = socket.channel("test-topic") // Test without custom timeout - try await channel.postSend(event: "test", message: ["data": "test"]) + try await channel.httpSend(event: "test", message: ["data": "test"]) let requests = await httpClient.receivedRequests XCTAssertEqual(requests.count, 1) } @MainActor - func testPostSendFallsBackToStatusTextWhenErrorBodyHasNoErrorField() async { + func testHttpSendFallsBackToStatusTextWhenErrorBodyHasNoErrorField() async { let httpClient = await HTTPClientMock() await httpClient.when({ _ in true }) { _ in let errorBody = try JSONEncoder().encode(["message": "Invalid request"]) @@ -406,15 +406,15 @@ final class RealtimeChannelTests: XCTestCase { let channel = socket.channel("test-topic") do { - try await channel.postSend(event: "test", message: ["data": "test"]) - XCTFail("Expected postSend to throw an error on 400 status") + try await channel.httpSend(event: "test", message: ["data": "test"]) + XCTFail("Expected httpSend to throw an error on 400 status") } catch { XCTAssertEqual(error.localizedDescription, "Invalid request") } } @MainActor - func testPostSendFallsBackToStatusTextWhenJSONParsingFails() async { + func testHttpSendFallsBackToStatusTextWhenJSONParsingFails() async { let httpClient = await HTTPClientMock() await httpClient.when({ _ in true }) { _ in HTTPResponse( @@ -442,8 +442,8 @@ final class RealtimeChannelTests: XCTestCase { let channel = socket.channel("test-topic") do { - try await channel.postSend(event: "test", message: ["data": "test"]) - XCTFail("Expected postSend to throw an error on 503 status") + try await channel.httpSend(event: "test", message: ["data": "test"]) + XCTFail("Expected httpSend to throw an error on 503 status") } catch { // Should fall back to localized status text XCTAssertTrue(error.localizedDescription.contains("503") || error.localizedDescription.contains("unavailable")) From 44062c4118bea74b3dc59bfc238e7f926056a900 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 9 Oct 2025 13:45:49 -0300 Subject: [PATCH 3/5] drop MainActor --- Sources/Realtime/RealtimeChannelV2.swift | 20 +++++++++--------- .../RealtimeTests/RealtimeChannelTests.swift | 21 +++++++------------ 2 files changed, 17 insertions(+), 24 deletions(-) diff --git a/Sources/Realtime/RealtimeChannelV2.swift b/Sources/Realtime/RealtimeChannelV2.swift index 2067397dd..03f1bb20d 100644 --- a/Sources/Realtime/RealtimeChannelV2.swift +++ b/Sources/Realtime/RealtimeChannelV2.swift @@ -93,7 +93,9 @@ public final class RealtimeChannelV2: Sendable, RealtimeChannelProtocol { /// Subscribes to the channel. public func subscribeWithError() async throws { - logger?.debug("Starting subscription to channel '\(topic)' (attempt 1/\(socket.options.maxRetryAttempts))") + logger?.debug( + "Starting subscription to channel '\(topic)' (attempt 1/\(socket.options.maxRetryAttempts))" + ) status = .subscribing @@ -259,7 +261,6 @@ public final class RealtimeChannelV2: Sendable, RealtimeChannelProtocol { /// - timeout: Optional timeout interval. If not specified, uses the socket's default timeout. /// - Returns: `true` if the message was accepted (HTTP 202), otherwise throws an error. /// - Throws: An error if the access token is missing, payload is missing, or the request fails. - @MainActor public func httpSend( event: String, message: some Codable, @@ -279,7 +280,6 @@ public final class RealtimeChannelV2: Sendable, RealtimeChannelProtocol { /// - timeout: Optional timeout interval. If not specified, uses the socket's default timeout. /// - Returns: `true` if the message was accepted (HTTP 202), otherwise throws an error. /// - Throws: An error if the access token is missing, payload is missing, or the request fails. - @MainActor public func httpSend( event: String, message: JSONObject, @@ -306,7 +306,7 @@ public final class RealtimeChannelV2: Sendable, RealtimeChannelProtocol { } } - let body = try JSONEncoder().encode( + let body = try await JSONEncoder().encode( BroadcastMessagePayload( messages: [ BroadcastMessagePayload.Message( @@ -365,9 +365,9 @@ public final class RealtimeChannelV2: Sendable, RealtimeChannelProtocol { public func broadcast(event: String, message: JSONObject) async { if status != .subscribed { logger?.warning( - "Realtime broadcast() is automatically falling back to REST API. " + - "This behavior will be deprecated in the future. " + - "Please use httpSend() explicitly for REST delivery." + "Realtime broadcast() is automatically falling back to REST API. " + + "This behavior will be deprecated in the future. " + + "Please use httpSend() explicitly for REST delivery." ) var headers: HTTPFields = [.contentType: "application/json"] @@ -650,7 +650,7 @@ public final class RealtimeChannelV2: Sendable, RealtimeChannelProtocol { table: table, filter: filter ) { - guard case let .insert(action) = $0 else { return } + guard case .insert(let action) = $0 else { return } callback(action) } } @@ -669,7 +669,7 @@ public final class RealtimeChannelV2: Sendable, RealtimeChannelProtocol { table: table, filter: filter ) { - guard case let .update(action) = $0 else { return } + guard case .update(let action) = $0 else { return } callback(action) } } @@ -688,7 +688,7 @@ public final class RealtimeChannelV2: Sendable, RealtimeChannelProtocol { table: table, filter: filter ) { - guard case let .delete(action) = $0 else { return } + guard case .delete(let action) = $0 else { return } callback(action) } } diff --git a/Tests/RealtimeTests/RealtimeChannelTests.swift b/Tests/RealtimeTests/RealtimeChannelTests.swift index 03ebc6037..4fdbaa67d 100644 --- a/Tests/RealtimeTests/RealtimeChannelTests.swift +++ b/Tests/RealtimeTests/RealtimeChannelTests.swift @@ -196,9 +196,8 @@ final class RealtimeChannelTests: XCTestCase { // The subscription is still in progress when we clean up } - @MainActor func testHttpSendThrowsWhenAccessTokenIsMissing() async { - let httpClient = await HTTPClientMock() + let httpClient = HTTPClientMock() let (client, _) = FakeWebSocket.fakes() let socket = RealtimeClientV2( @@ -218,9 +217,8 @@ final class RealtimeChannelTests: XCTestCase { } } - @MainActor func testHttpSendSucceedsOn202Status() async throws { - let httpClient = await HTTPClientMock() + let httpClient = HTTPClientMock() await httpClient.when({ _ in true }) { _ in HTTPResponse( data: Data(), @@ -267,9 +265,8 @@ final class RealtimeChannelTests: XCTestCase { XCTAssertEqual(body.messages[0].private, true) } - @MainActor func testHttpSendThrowsOnNon202Status() async { - let httpClient = await HTTPClientMock() + let httpClient = HTTPClientMock() await httpClient.when({ _ in true }) { _ in let errorBody = try JSONEncoder().encode(["error": "Server error"]) return HTTPResponse( @@ -304,9 +301,8 @@ final class RealtimeChannelTests: XCTestCase { } } - @MainActor func testHttpSendRespectsCustomTimeout() async throws { - let httpClient = await HTTPClientMock() + let httpClient = HTTPClientMock() await httpClient.when({ _ in true }) { _ in HTTPResponse( data: Data(), @@ -340,9 +336,8 @@ final class RealtimeChannelTests: XCTestCase { XCTAssertEqual(requests.count, 1) } - @MainActor func testHttpSendUsesDefaultTimeoutWhenNotSpecified() async throws { - let httpClient = await HTTPClientMock() + let httpClient = HTTPClientMock() await httpClient.when({ _ in true }) { _ in HTTPResponse( data: Data(), @@ -376,9 +371,8 @@ final class RealtimeChannelTests: XCTestCase { XCTAssertEqual(requests.count, 1) } - @MainActor func testHttpSendFallsBackToStatusTextWhenErrorBodyHasNoErrorField() async { - let httpClient = await HTTPClientMock() + let httpClient = HTTPClientMock() await httpClient.when({ _ in true }) { _ in let errorBody = try JSONEncoder().encode(["message": "Invalid request"]) return HTTPResponse( @@ -413,9 +407,8 @@ final class RealtimeChannelTests: XCTestCase { } } - @MainActor func testHttpSendFallsBackToStatusTextWhenJSONParsingFails() async { - let httpClient = await HTTPClientMock() + let httpClient = HTTPClientMock() await httpClient.when({ _ in true }) { _ in HTTPResponse( data: Data("Invalid JSON".utf8), From fabe331a34b699102342b021c1fd3309e7709569 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Fri, 10 Oct 2025 09:28:38 -0300 Subject: [PATCH 4/5] simplify try/catch --- Sources/Realtime/RealtimeChannelV2.swift | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/Sources/Realtime/RealtimeChannelV2.swift b/Sources/Realtime/RealtimeChannelV2.swift index 03f1bb20d..ef4dbe912 100644 --- a/Sources/Realtime/RealtimeChannelV2.swift +++ b/Sources/Realtime/RealtimeChannelV2.swift @@ -326,23 +326,16 @@ public final class RealtimeChannelV2: Sendable, RealtimeChannelProtocol { body: body ) - let response: Helpers.HTTPResponse - do { - response = try await withTimeout(interval: timeout ?? socket.options.timeoutInterval) { [self] in - await Result { - try await socket.http.send(request) - } - }.get() - } catch is TimeoutError { - throw RealtimeError("Request timeout") - } catch { - throw error - } + let response = try await withTimeout(interval: timeout ?? socket.options.timeoutInterval) { [self] in + await Result { + try await socket.http.send(request) + } + }.get() guard response.statusCode == 202 else { // Try to parse error message from response body var errorMessage = HTTPURLResponse.localizedString(forStatusCode: response.statusCode) - if let errorBody = try? JSONDecoder().decode([String: String].self, from: response.data) { + if let errorBody = try? response.decoded(as: [String: String].self) { errorMessage = errorBody["error"] ?? errorBody["message"] ?? errorMessage } throw RealtimeError(errorMessage) From 44ffb461f0903e987b5f8f0c81769bb7f9df44a3 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Fri, 10 Oct 2025 09:30:05 -0300 Subject: [PATCH 5/5] reuse BroadcastMessagePayload --- Sources/Realtime/RealtimeChannelV2.swift | 23 +---------------------- Sources/Realtime/Types.swift | 11 +++++++++++ 2 files changed, 12 insertions(+), 22 deletions(-) diff --git a/Sources/Realtime/RealtimeChannelV2.swift b/Sources/Realtime/RealtimeChannelV2.swift index ef4dbe912..0cda4efdf 100644 --- a/Sources/Realtime/RealtimeChannelV2.swift +++ b/Sources/Realtime/RealtimeChannelV2.swift @@ -295,17 +295,6 @@ public final class RealtimeChannelV2: Sendable, RealtimeChannelProtocol { } headers[.authorization] = "Bearer \(accessToken)" - struct BroadcastMessagePayload: Encodable { - let messages: [Message] - - struct Message: Encodable { - let topic: String - let event: String - let payload: JSONObject - let `private`: Bool - } - } - let body = try await JSONEncoder().encode( BroadcastMessagePayload( messages: [ @@ -371,17 +360,6 @@ public final class RealtimeChannelV2: Sendable, RealtimeChannelProtocol { headers[.authorization] = "Bearer \(accessToken)" } - struct BroadcastMessagePayload: Encodable { - let messages: [Message] - - struct Message: Encodable { - let topic: String - let event: String - let payload: JSONObject - let `private`: Bool - } - } - let task = Task { [headers] in _ = try? await socket.http.send( HTTPRequest( @@ -773,3 +751,4 @@ public final class RealtimeChannelV2: Sendable, RealtimeChannelProtocol { push?.didReceive(status: PushStatus(rawValue: status) ?? .ok) } } + diff --git a/Sources/Realtime/Types.swift b/Sources/Realtime/Types.swift index 30d625e06..cd0a44c3a 100644 --- a/Sources/Realtime/Types.swift +++ b/Sources/Realtime/Types.swift @@ -110,3 +110,14 @@ extension HTTPField.Name { public enum LogLevel: String, Sendable { case info, warn, error } + +struct BroadcastMessagePayload: Encodable { + let messages: [Message] + + struct Message: Encodable { + let topic: String + let event: String + let payload: JSONObject + let `private`: Bool + } +}