ACActionCable
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.
Installation
CocoaPods
If your project doesn't use CocoaPods yet, follow this guide.
Add the following line to your Podfile
:
pod 'ACActionCable', '~> 1.0'
ACActionCable uses semantic versioning.
Usage
Implement ACWebSocketProtocol
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.
singleton class to hold an ACClient
Create a// 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.
Connect and disconnect
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()
}
Subscribe and unsubscribe
// 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.
Decodable
messages
Register your 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 can automatically decode it into the following object:
// 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 object.
// MyClient.swift
private init() {
// ...
ACMessageBodyObject.register(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 (_, .dictionary(let dictionary)):
switch dictionary.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
}
}
Send messages
ACActionCable automatically encodes your Encodable
objects too:
// MyObject.swift
struct MyObject: Codable { // Must implement Encodable or Codable
let senderId: Int
let text: String
let sentAt: Date
}
// ChatChannel.swift
func speak(_ text: String) {
let myObject = MyObject(senderId: 99, text: text, sentAt: Date())
subscription?.send(actionName: "speak", 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}"
}
(Optional) Modify encoder/decoder date formatting
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
.
(Optional) Add an ACClientTap
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)
}
Contributing
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!