Signed streams
AnyCable allows you to subscribe to streams without using channels (in Action Cable terminology). Channels is a great way to encapsulate business-logic for a given real-time feature, but in many cases all we need is a good old explicit pub/sub. That's where the signed streams feature comes into play.
You read more about the Action Cable abstract design, how it compares to direct pub/sub and what are the pros and cons from this Any Cables Monthly issue. Don't forget to subscribe!
Signed streams work as follows:
Given a stream name, say, "chat/2024", you generate its signed version using a secret key (see below on the signing algorithm)
On the client side, you subscribe to the "$pubsub" channel and provide the signed stream name as a
signed_stream_name
parameterAnyCable process the subscribe command, verifies the stream name and completes the subscription (if verified).
For verification, you MUST provide the secret key via the --streams_secret
(ANYCABLE_STREAMS_SECRET
) parameter for AnyCable.
Full-stack example: Rails
Let's consider an example of using signed stream in a Rails application.
Assume that we want to subscribe a user with ID=17 to their personal notifications channel, "notifications/17".
First, we need to generate a signed stream name:
signed_name = AnyCable::Streams.signed("notifications/17")
Or you can use the #signed_stream_name
helper in your views
<div
data-controller="notifications"
data-notifications-stream="<%= signed_stream_name("notifications/#{current_user.id}") %>">
</div>
By default, AnyCable uses Rails.application.secret_key_base
to sign streams. We recommend configuring a custom secret though (so you can easily rotate values at both ends, the Rails app and AnyCable servers). You can specify it via the streams_secret
configuration parameter (in anycable.yml
, credentials, or environment).
Then, on the client side, you can subscribe to this stream as follows:
// using @rails/actioncable
let subscription = consumer.subscriptions.create(
{channel: "$pubsub", signed_stream_name: stream},
{
received: (msg) => {
// handle notification msg
}
}
)
// using @anycable/web
let channel = cable.streamFromSigned(stream);
channel.on("message", (msg) => {
// handle notification
})
Now you can broadcast messages to this stream as usual:
ActionCable.server.broadcast "notifications/#{user.id}", payload
Public (unsigned) streams
Sometimes you may want to skip all the signing ceremony and use plain stream names instead. With AnyCable, you can do that by enabling the --public_streams
option (or ANYCABLE_PUBLIC_STREAMS=true
) for the AnyCable server:
$ anycable-go --public_streams
# or
$ ANYCABLE_PUBLIC_STREAMS=true anycable-go
With public streams enabled, you can subscribe to them as follows:
// using @rails/actioncable
let subscription = consumer.subscriptions.create(
{channel: "$pubsub", stream_name: "notifications/17"},
{
received: (msg) => {
// handle notification msg
}
}
)
// using @anycable/web
let channel = cable.streamFrom("notifications/17");
channel.on("message", (msg) => {
// handle notification
})
Signing algorithm
We use the same algorithm as Rails uses in its MessageVerifier:
- Encode the stream name by first converting it into a JSON string and then encoding in Base64 format.
- Calculate a HMAC digest using the SHA256 hash function from the secret and the encoded stream name.
- Concatenate the encoded stream name, a double dash (
--
), and the digest.
Here is the Ruby version of the algorithm:
encoded = ::Base64.strict_encode64(JSON.dump(stream_name))
digest = OpenSSL::HMAC.hexdigest("SHA256", SECRET_KEY, encoded)
signed_stream_name = "#{encoded}--#{digest}"
The JavaScript (Node.js) version:
import { createHmac } from 'crypto';
const encoded = Buffer.from(JSON.stringify(stream_name)).toString('base64');
const digest = createHmac('sha256', SECRET_KEY).update(encoded).digest('hex');
const signedStreamName = `${encoded}--${digest}`;
The Python version looks as follows:
import base64
import json
import hmac
import hashlib
encoded = base64.b64encode(json.dumps(stream_name).encode('utf-8')).decode('utf-8')
digest = hmac.new(SECRET_KEY.encode('utf-8'), encoded.encode('utf-8'), hashlib.sha256).hexdigest()
signed_stream_name = f"{encoded}--{digest}"
The PHP version is as follows:
$encoded = base64_encode(json_encode($stream_name));
$digest = hash_hmac('sha256', $encoded, $SECRET_KEY);
$signed_stream_name = $encoded . '--' . $digest;
Whispering
Whispering is an ability to publish transient broadcasts from clients, i.e., without touching your backend. This is useful when you want to share client-only information from one connection to others. Typical examples include typing indicators, cursor position sharing, etc.
Whispering must be enabled explicitly for signed streams via the --streams_whisper
(ANYCABLE_STREAMS_WHISPER=true
) option. Public streams always allow whispering.
Here is an example client code using AnyCable JS SDK:
let channel = cable.streamFrom("chat/22");
channel.on("message", (msg) => {
if (msg.event === "typing") {
console.log(`user ${msg.name} is typing`);
}
})
// publishing whispers
channel.whisper({event: "typing", name: user.name})
Hotwire and CableReady support
AnyCable can be used to serve Hotwire (Turbo Streams) and CableReady (v5+) subscriptions right at the real-time server using the same signed streams functionality under the hood (and, thus, without performing any RPC calls to authorize subscriptions).
In combination with JWT authentication, this feature makes it possible to run AnyCable in a standalone mode for Hotwire/CableReady applications.
🎥 Check out this AnyCasts episode to learn how to use AnyCable with Hotwire Rails application in a RPC-less way.
You must explicitly enable Turbo Streams or CableReady signed streams support at the AnyCable server side by specifying the --turbo_streams
(ANYCABLE_TURBO_STREAMS=true
) or --cable_ready_streams
(ANYCABLE_CABLE_READY_STREAMS=true
) option respectively.
You must also provide the --streams_secret
corresponding to the secret you use for Turbo/CableReady. You can configure them in your Rails application as follows:
# Turbo configuration
# config/environments/production.rb
config.turbo.signed_stream_verifier_key = "<SECRET>"
# CableReady configuration
# config/initializers/cable_ready.rb
CableReady.configure do |config|
config.verifier_key = "<SECRET>"
end
You can also specify custom secrets for Turbo Streams and CableReady via the --turbo_streams_secret
and --cable_ready_secret
parameters respectively.