ACActionCable is a Swift 5 client for Ruby on Rails 6's Action Cable WebSocket server. It is a hard fork of Action-Cable-Swift. It aims to be well-tested, dependency-free, and easy to use.
If your project doesn't use CocoaPods yet, follow this guide.
Add the following line to your Podfile
:
pod 'ACActionCable', '~> 2'
ACActionCable uses semantic versioning.
You can use ACActionCable with any WebSocket library you'd like. Just create a class that implements ACWebSocketProtocol
. If you use Starscream, you can just copy ACStarscreamWebSocket
into your project.
Create a singleton class to hold an ACClient
// MyClient.swift
import ACActionCable
class MyClient {
static let shared = MyClient()
private let client: ACClient
private init() {
let socket = ACStarscreamWebSocket(stringURL: "https://myrailsapp.com/cable") // Your concrete implementation of ACWebSocketProtocol (see above)
client = ACClient(socket: socket, connectionMonitorTimeout: 6)
}
}
If you set a connectionMonitorTimeout
and no ping is received for that many seconds, then ACConnectionMonitor
will periodically attempt to reconnect. Leave connectionMonitorTimeout
nil to disable connection monitoring.
You can set custom headers based on your server's requirements
// MyClient.swift
func connect() {
client.headers = [
"Auth": "Token",
"Origin": "https://myrailsapp.com",
]
client.connect()
}
func disconnect() {
client.disconnect()
}
You probably want to connect when the user's session begins and disconnect when the user logs out.
// User.swift
func onSessionCreated() {
MyClient.shared.connect()
// ...
}
func logOut() {
// ...
MyClient.shared.disconnect()
}
// MyClient.swift
func subscribe(to channelIdentifier: ACChannelIdentifier, with messageHandler: @escaping ACMessageHandler) -> ACSubscription? {
guard let subscription = client.subscribe(to: channelIdentifier, with: messageHandler) else {
print("Warning: MyClient ignored attempt to double subscribe. You are already subscribed to \(channelIdentifier)")
return nil
}
return subscription
}
func unsubscribe(from subscription: ACSubscription) {
client.unsubscribe(from: subscription)
}
// ChatChannel.swift
import ACActionCable
class ChatChannel {
private var subscription: ACSubscription?
func subscribe(to roomId: Int) {
guard subscription == nil else { return }
let channelIdentifier = ACChannelIdentifier(channelName: "ChatChannel", identifier: ["room_id": roomId])!
subscription = MyClient.shared.subscribe(to: channelIdentifier, with: handleMessage(_:))
}
func unsubscribe() {
guard let subscription = subscription else { return }
MyClient.shared.unsubscribe(from: subscription)
self.subscription = nil
}
private func handleMessage(_ message: ACMessage) {
switch message.type {
case .confirmSubscription:
print("ChatChannel subscribed")
case .rejectSubscription:
print("Server rejected ChatChannel subscription")
default:
break
}
}
}
Subscriptions are resubscribed on reconnection, so beware that .confirmSubscription
may be called multiple times per subscription.
Register your Decodable
messages
ACActionCable automatically decodes your models. For example, if your server broadcasts the following message:
{
"identifier": "{\"channel\":\"ChatChannel\",\"room_id\":42}",
"message": {
"my_object": {
"sender_id": 311,
"text": "Hello, room 42!",
"sent_at": 1600545466.294104
}
}
}
Then ACActionCable provides two different approaches to automatically decode it:
// MyObject.swift
struct MyObject: Codable { // Must implement Decodable or Codable
let senderId: Int
let text: String
let sentAt: Date
}
All you have to do is register the Codable
struct for a single key. The name of the struct
must match the name of that single key.
// MyClient.swift
private init() {
// Decode the single object for key `my_object` within `message`
ACMessageBodySingleObject.register(type: MyObject.self)
}
// ChatChannel.swift
private func handleMessage(_ message: ACMessage) {
switch (message.type, message.body) {
case (.confirmSubscription, _):
print("ChatChannel subscribed")
case (.rejectSubscription, _):
print("Server rejected ChatChannel subscription")
case (_, .object(let object)):
switch object {
case let myObject as MyObject:
print("\(myObject.text.debugDescription) from Sender \(myObject.senderId) at \(myObject.sentAt)")
// "Hello, room 42!" from Sender 311 at 2020-09-19 19:57:46 +0000
default:
print("Warning: ChatChannel ignored message")
}
default:
break
}
}
If the message
object sent from the server contains more than a single key, you have another option for automatic decoding:
// MessageType.swift
struct MessageType: Codable { // Must implement Decodable or Codable
let myObject: MyObject
}
struct MyObject: Codable { // Must implement Decodable or Codable
let senderId: Int
let text: String
let sentAt: Date
}
All you have to do is register the Codable
struct for the whole message
object.
// MyClient.swift
private init() {
// Decode the whole `message` object
ACMessage.register(type: MessageType.self, forChannelIdentifier: channelIdentifier)
}
The channelIdentifier
must match the one the handler is subscribing to, because all incoming message
objects for that channel will be decoded according to MessageType
.
// ChatChannel.swift
private func handleMessage(_ message: ACMessage) {
switch (message.type, message.body) {
case (.confirmSubscription, _):
print("ChatChannel subscribed")
case (.rejectSubscription, _):
print("Server rejected ChatChannel subscription")
case (_, .object(let object)):
switch object {
case let message as MessageType:
print("\(message.myObject.text.debugDescription) from Sender \(message.myObject.senderId) at \(message.myObject.sentAt)")
// "Hello, room 42!" from Sender 311 at 2020-09-19 19:57:46 +0000
default:
print("Warning: ChatChannel ignored message")
}
default:
break
}
}
ACActionCable also provides access to the raw message body data for more involved processing:
// ChatChannel.swift
private func handleMessage(_ message: ACMessage) {
switch (message.type) {
case (.confirmSubscription):
print("ChatChannel subscribed")
case (.rejectSubscription):
print("Server rejected ChatChannel subscription")
default:
guard let bodyData = message.bodyData else {
return
}
// do something fancy with the raw `Data`
}
}
ACActionCable automatically encodes your Encodable
objects too:
// MyObject.swift
struct MyObject: Codable { // Must implement Encodable or Codable
let action: String
let senderId: Int
let text: String
let sentAt: Date
}
// ChatChannel.swift
func speak(_ text: String) {
let myObject = MyObject(action: "speak", senderId: 99, text: text, sentAt: Date())
subscription?.send(object: myObject)
}
Calling channel.speak("my message")
would cause the following to be sent:
{
"command": "message",
"data": "{\"action\":\"speak\",\"my_object\":{\"sender_id\":99,\"sent_at\":1600545466.294104,\"text\":\"my message\"}}",
"identifier": "{\"channel\":\"ChatChannel\",\"room_id\":42}"
}
By default, Date
objects are encoded or decoded using .secondsSince1970
. If you need to change to another format:
ACCommand.encoder.dateEncodingStrategy = .iso8601 // for dates like "2020-09-19T20:09:04Z"
ACMessage.decoder.dateDecodingStrategy = .iso8601
Note that .iso8601
is quite strict and doesn't allow fractional seconds. If you need them, consider using .secondsSince1970
, millisecondsSince1970
, .formatted
, or .custom
.
If you need to listen to the internal state of ACClient
, use ACClientTap
.
// MyClient.swift
private init() {
// ...
let tap = ACClientTap(
onConnected: { (headers) in
print("Client connected with headers: \(headers.debugDescription)")
}, onDisconnected: { (reason) in
print("Client disconnected with reason: \(reason.debugDescription)")
}, onText: { (text) in
print("Client received text: \(text)")
}, onMessage: { (message) in
print("Client received message: \(message)")
})
client.add(tap)
}
Instead of opening an issue, please fix it yourself and then create a pull request. Please add new tests for your feature or fix, and don't forget to make sure that all the tests pass!