Skip to content

Commit 20d761a

Browse files
grdsdevclaude
andcommitted
feat(realtime): implement V2 serializer with binary payload support
This implements the Realtime V2 serializer based on supabase-js PRs #1829 and #1894. Key features: - Binary payload support for user messages - Two new message types: user broadcast and user broadcast push - Optional metadata support for user broadcast push messages - Reduced JSON encoding overhead on the server side - Backward compatible with V1 (1.0.0) as default Changes: - Added RealtimeBinaryEncoder and RealtimeBinaryDecoder classes - Added RealtimeSerializer protocol for future extensibility - Updated RealtimeClientOptions to support serializer version selection - Updated RealtimeClientV2 to use binary serializer when v2.0.0 is selected - Added RealtimeBinaryPayload helper for working with binary data - Comprehensive test suite with 16 tests covering encoding/decoding scenarios References: - supabase/supabase-js#1829 - supabase/supabase-js#1894 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent af786cb commit 20d761a

9 files changed

+979
-27
lines changed
Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
//
2+
// RealtimeBinaryDecoder.swift
3+
//
4+
//
5+
// Created by Guilherme Souza on 05/12/24.
6+
//
7+
8+
import Foundation
9+
10+
/// Binary decoder for Realtime V2 messages.
11+
///
12+
/// Supports decoding messages with:
13+
/// - Binary payloads
14+
/// - User broadcast messages with metadata
15+
/// - Push, reply, broadcast, and user broadcast message types
16+
final class RealtimeBinaryDecoder: Sendable {
17+
private let headerLength = 1
18+
private let metaLength = 4
19+
20+
enum MessageKind: UInt8 {
21+
case push = 0
22+
case reply = 1
23+
case broadcast = 2
24+
case userBroadcastPush = 3
25+
case userBroadcast = 4
26+
}
27+
28+
enum PayloadEncoding: UInt8 {
29+
case binary = 0
30+
case json = 1
31+
}
32+
33+
/// Decodes binary data into a Realtime message.
34+
/// - Parameter data: Binary data to decode
35+
/// - Returns: Decoded message
36+
func decode(_ data: Data) throws -> RealtimeMessageV2 {
37+
guard !data.isEmpty else {
38+
throw RealtimeError("Empty binary data")
39+
}
40+
41+
let kind = data[0]
42+
43+
guard let messageKind = MessageKind(rawValue: kind) else {
44+
throw RealtimeError("Unknown message kind: \(kind)")
45+
}
46+
47+
switch messageKind {
48+
case .push:
49+
return try decodePush(data)
50+
case .reply:
51+
return try decodeReply(data)
52+
case .broadcast:
53+
return try decodeBroadcast(data)
54+
case .userBroadcast:
55+
return try decodeUserBroadcast(data)
56+
case .userBroadcastPush:
57+
throw RealtimeError("userBroadcastPush should not be received from server")
58+
}
59+
}
60+
61+
// MARK: - Private Decoding Methods
62+
63+
private func decodePush(_ data: Data) throws -> RealtimeMessageV2 {
64+
guard data.count >= headerLength + metaLength - 1 else {
65+
throw RealtimeError("Invalid push message length")
66+
}
67+
68+
let joinRefSize = Int(data[1])
69+
let topicSize = Int(data[2])
70+
let eventSize = Int(data[3])
71+
72+
var offset = headerLength + metaLength - 1 // pushes have no ref
73+
74+
let joinRef = try decodeString(from: data, offset: offset, length: joinRefSize)
75+
offset += joinRefSize
76+
77+
let topic = try decodeString(from: data, offset: offset, length: topicSize)
78+
offset += topicSize
79+
80+
let event = try decodeString(from: data, offset: offset, length: eventSize)
81+
offset += eventSize
82+
83+
let payloadData = data.subdata(in: offset..<data.count)
84+
let payload = try JSONSerialization.jsonObject(with: payloadData, options: [])
85+
let jsonPayload = try AnyJSON(value: payload).objectValue ?? [:]
86+
87+
return RealtimeMessageV2(
88+
joinRef: joinRef,
89+
ref: nil,
90+
topic: topic,
91+
event: event,
92+
payload: jsonPayload
93+
)
94+
}
95+
96+
private func decodeReply(_ data: Data) throws -> RealtimeMessageV2 {
97+
guard data.count >= headerLength + metaLength else {
98+
throw RealtimeError("Invalid reply message length")
99+
}
100+
101+
let joinRefSize = Int(data[1])
102+
let refSize = Int(data[2])
103+
let topicSize = Int(data[3])
104+
let eventSize = Int(data[4])
105+
106+
var offset = headerLength + metaLength
107+
108+
let joinRef = try decodeString(from: data, offset: offset, length: joinRefSize)
109+
offset += joinRefSize
110+
111+
let ref = try decodeString(from: data, offset: offset, length: refSize)
112+
offset += refSize
113+
114+
let topic = try decodeString(from: data, offset: offset, length: topicSize)
115+
offset += topicSize
116+
117+
let event = try decodeString(from: data, offset: offset, length: eventSize)
118+
offset += eventSize
119+
120+
let responseData = data.subdata(in: offset..<data.count)
121+
let response = try JSONSerialization.jsonObject(with: responseData, options: [])
122+
let jsonResponse = try AnyJSON(value: response)
123+
124+
// Reply messages have status in the event field and response in payload
125+
let payload: JSONObject = [
126+
"status": .string(event),
127+
"response": jsonResponse,
128+
]
129+
130+
return RealtimeMessageV2(
131+
joinRef: joinRef,
132+
ref: ref,
133+
topic: topic,
134+
event: "phx_reply",
135+
payload: payload
136+
)
137+
}
138+
139+
private func decodeBroadcast(_ data: Data) throws -> RealtimeMessageV2 {
140+
guard data.count >= headerLength + 2 else {
141+
throw RealtimeError("Invalid broadcast message length")
142+
}
143+
144+
let topicSize = Int(data[1])
145+
let eventSize = Int(data[2])
146+
147+
var offset = headerLength + 2
148+
149+
let topic = try decodeString(from: data, offset: offset, length: topicSize)
150+
offset += topicSize
151+
152+
let event = try decodeString(from: data, offset: offset, length: eventSize)
153+
offset += eventSize
154+
155+
let payloadData = data.subdata(in: offset..<data.count)
156+
let payload = try JSONSerialization.jsonObject(with: payloadData, options: [])
157+
let jsonPayload = try AnyJSON(value: payload).objectValue ?? [:]
158+
159+
return RealtimeMessageV2(
160+
joinRef: nil,
161+
ref: nil,
162+
topic: topic,
163+
event: event,
164+
payload: jsonPayload
165+
)
166+
}
167+
168+
private func decodeUserBroadcast(_ data: Data) throws -> RealtimeMessageV2 {
169+
guard data.count >= headerLength + 4 else {
170+
throw RealtimeError("Invalid user broadcast message length")
171+
}
172+
173+
let topicSize = Int(data[1])
174+
let userEventSize = Int(data[2])
175+
let metadataSize = Int(data[3])
176+
let payloadEncoding = data[4]
177+
178+
var offset = headerLength + 4
179+
180+
let topic = try decodeString(from: data, offset: offset, length: topicSize)
181+
offset += topicSize
182+
183+
let userEvent = try decodeString(from: data, offset: offset, length: userEventSize)
184+
offset += userEventSize
185+
186+
let metadata = try decodeString(from: data, offset: offset, length: metadataSize)
187+
offset += metadataSize
188+
189+
let payloadData = data.subdata(in: offset..<data.count)
190+
191+
var payload: JSONObject = [
192+
"type": .string("broadcast"),
193+
"event": .string(userEvent),
194+
]
195+
196+
// Decode payload based on encoding type
197+
if payloadEncoding == PayloadEncoding.json.rawValue {
198+
let jsonPayload = try JSONSerialization.jsonObject(with: payloadData, options: [])
199+
payload["payload"] = try AnyJSON(value: jsonPayload)
200+
} else {
201+
// Binary payload - store as a special marker object with base64-encoded data
202+
payload["payload"] = .object([
203+
"__binary__": .bool(true),
204+
"data": .string(payloadData.base64EncodedString()),
205+
])
206+
}
207+
208+
// Add metadata if present
209+
if !metadata.isEmpty, let metadataData = metadata.data(using: .utf8) {
210+
let metaObject = try JSONSerialization.jsonObject(with: metadataData, options: [])
211+
payload["meta"] = try AnyJSON(value: metaObject)
212+
}
213+
214+
return RealtimeMessageV2(
215+
joinRef: nil,
216+
ref: nil,
217+
topic: topic,
218+
event: "broadcast",
219+
payload: payload
220+
)
221+
}
222+
223+
// MARK: - Helper Methods
224+
225+
private func decodeString(from data: Data, offset: Int, length: Int) throws -> String {
226+
guard offset + length <= data.count else {
227+
throw RealtimeError("Invalid string offset/length")
228+
}
229+
230+
let stringData = data.subdata(in: offset..<(offset + length))
231+
guard let string = String(data: stringData, encoding: .utf8) else {
232+
throw RealtimeError("Failed to decode string")
233+
}
234+
return string
235+
}
236+
}
237+
238+
// MARK: - AnyJSON Extensions for Binary Support
239+
240+
extension AnyJSON {
241+
/// Creates an AnyJSON value from a Swift value.
242+
init(value: Any) throws {
243+
if let dict = value as? [String: Any] {
244+
var object: JSONObject = [:]
245+
for (key, val) in dict {
246+
object[key] = try AnyJSON(value: val)
247+
}
248+
self = .object(object)
249+
} else if let array = value as? [Any] {
250+
self = .array(try array.map { try AnyJSON(value: $0) })
251+
} else if let string = value as? String {
252+
self = .string(string)
253+
} else if let bool = value as? Bool {
254+
// Bool must be checked before Int because Bool can be cast to Int
255+
self = .bool(bool)
256+
} else if let int = value as? Int {
257+
self = .integer(int)
258+
} else if let double = value as? Double {
259+
self = .double(double)
260+
} else if value is NSNull {
261+
self = .null
262+
} else {
263+
throw RealtimeError("Unsupported JSON value type: \(type(of: value))")
264+
}
265+
}
266+
}

0 commit comments

Comments
 (0)