[AnyCable][anycable] brings performance and scalability to real-time applications built with Ruby and Rails. It uses [Action Cable protocol][protocol] and its extensions for client-server communication.
This repository contains JavaScript packages to build AnyCable clients.
Multiple reasons that forced us to implement an alternative client library for Action Cable / AnyCable:
📖 Read also the introductory post.
npm install @anycable/web
# or
yarn add @anycable/web
First, you need to create a client (or consumer as it's called in Action Cable):
// cable.js
import { createCable } from '@anycable/web'
export default createCable()
By default, the connection URL is looked up in meta tags (action-cable-url or cable-url), and if none found, fallbacks to /cable. You can also specify the URL explicitly:
createCable('ws://cable.example.com/my_cable')
[!IMPORTANT] This feature is backed by AnyCable signed streams (available since v1.5). See the documentation.
You can subscribe directly to data streams as follows:
const cable = createCable();
const chatChannel = cable.streamFrom('room/42');
chatChannel.on('message', (msg) => {
// ...
});
// Publish transient events
chatChannel.whisper({event: 'typing', user: '<name>'});
In most cases, however, you'd prefer to use secured (signed) stream names generated by your backend:
const cable = createCable();
const signedName = await obtainSignedStreamNameFromWhenever();
const chatChannel = cable.streamFromSigned(signedName);
// ...
[!IMPORTANT] This feature is currently supported only by AnyCable+ and edge version of AnyCable server. See the documentation.
You can keep track of the users currently connected to the channel. Let's assume you have the following channel:
const cable = createCable();
const chatChannel = cable.streamFrom('room/42');
To join the channel's presence set, you must explicitly provide the user's information:
// The first argument must be a unique user identifier within the channel
// and the second argument is an arbitrary user data (presence information)
chatChannel.presence.join(user.id, { name: user.name })
You MUST join the presence once, no need to do that on every connection or reconnection—our library takes care of this.
You can subscribe to presence events:
chatChannel.presence.on('presence', (ev) => {
const { type, info, id } = ev
// Type could be 'join', 'leave', 'presence', or 'error'
if (type === 'join') {
console.log("user joined", id, info);
}
if (type === 'leave') {
// no info, just id
console.log("user left", id);
}
})
To obtain the current presence state, you can use the info function:
const users = await chatChannel.presence.info()
// users is an object with user ids as keys and user data as values
users //=> { 'user-id': { name: 'John' }, ... }
Calling presence.info() performs a server request on the initial invocation (or when necessary) and uses join / leave events for keeping the
information up-to-date.
Note that it's not necessary to join the channel to obtain the presence information.
You can also leave the channel as follows:
chatChannel.presence.leave()
The users leaves the channel automatically on unsubscribe or disconnect (in this case, the presense state update might be delayed depending on the server-side configuration).
AnyCable client provides multiple ways to subscribe to channels: class-based subscriptions and headless subscriptions.
[!TIP] Read more about the concept of channels and how AnyCable uses it here.
Class-based APIs allows provides an abstraction layer to hide implementation details of subscriptions. You can add additional API methods, dispatch custom events, etc.
Let's consider an example:
import { Channel } from '@anycable/web'
// channels/chat.js
export default class ChatChannel extends Channel {
// Unique channel identifier (channel class for Action Cable)
static identifier = 'ChatChannel'
async speak(message) {
return this.perform('speak', { message })
}
receive(message) {
if (message.type === 'typing') {
// Emit custom event when message type is 'typing'
return this.emit('typing', message)
}
// Fallback to the default behaviour
super.receive(message)
}
}
import cable from 'cable'
import { ChatChannel } from 'channels/chat'
// Build an instance of a ChatChannel class.
const channel = new ChatChannel({ roomId: '42' })
// Subscribe to the server channel via the client.
cable.subscribe(channel) // return channel itself for chaining
// Wait for subscription confirmation or rejection
// NOTE: it's not necessary to do that, you can perform actions right away,
// the channel would wait for connection automatically
await channel.ensureSubscribed()
// Perform an action
// NOTE: Action Cable doesn't implement a full-featured RPC with ACK messages,
// so return value is always undefined
let _ = await channel.speak('Hello')
// Handle incoming messages
channel.on('message', msg => console.log(`${msg.name}: ${msg.text}`))
// Handle custom typing messages
channel.on('typing', msg => console.log(`User ${msg.name} is typing`))
// Or subscription close events
channel.on('close', () => console.log('Disconnected from chat'))
// Or temporary disconnect
channel.on('disconnect', () => console.log('No chat connection'))
// Unsubscribe from the channel (results in a 'close' event)
channel.disconnect()
IMPORTANT: cable.subscribe(channel) is optimistic: it doesn't require the cable to be connected, and waits for it to connect before performing a subscription request. Even if the cable got disconnected before subscription was confirmed or rejected, a new attempt is made as soon as the connectivity restored.
Calling channel.disconnect() removes the subscription for this channel right away and send unsubscribe request asynchronously; if there is no connectivity, we assume that the server takes care of performing unsubscribe tasks, so we don't need to retry them.
Headless subscriptions are very similar to Action Cable client-side subscriptions except from the fact that no mixins are allowed (you classes in case you need them).
Let's rewrite the same example using headless subscriptions:
import cable from 'cable'
const subscription = cable.subscribeTo('ChatChannel', { roomId: '42' })
const _ = await subscription.perform('speak', { msg: 'Hello' })
subscription.on('message', msg => {
if (msg.type === 'typing') {
console.log(`User ${msg.name} is typing`)
} else {
console.log(`${msg.name}: ${msg.text}`)
}
})
We provide an Action Cable compatible APIs for smoother migrations.
All you need is to change the imports:
- import { createConsumer } from "@rails/actioncable";
+ import { createConsumer } from "@anycable/web";
// createConsumer accepts all the options available to createCable
export default createConsumer();
Then you can use consumer.subscriptions.create as before (under the hood a headless channel would be create).
Both cables and channels allow you to subscribe to various lifecycle events for better observability.
Learn more from the dedicated documentation.
AnyCable client provides automatic reconnection on network failure out-of-the-box. Under the hood, it uses the exponential backoff with jitter algorithm to make reconnection attempts non-deterministic (and, thus, prevent thundering herd attacks on the server). You can read more about it in the blog post.
The component responsible for reconnection is called Monitor, and it's created automatically, if you use the createCable (or createConsumer) function.
Sometimes it might be useful to disable reconnection. In that case, you MUST pass the monitor: false to the createCable function:
cable = createCable({monitor: false})
You can make your channels more strict by adding type constraints for parameters, incoming message types and custom events:
// ChatChannel.ts
import { Channel, ChannelEvents } from '@anycable/web'
type Params = {
roomId: string | number
}
type TypingMessage = {
type: 'typing'
username: string
}
type ChatMessage = {
type: 'message'
username: string
userId: string
}
type Message = TypingMessage | ChatMessage
interface Events extends ChannelEvents<Message> {
typing: (msg: TypingMessage) => void
}
// Which actions can be performed on this channel
interface Actions {
speak: (msg: ChatMessage) => void
typing: () => void
}
export class ChatChannel extends Channel<Params,Message,Events,Actions> {
static identifier = 'ChatChannel'
receive(message: Message) {
if (message.type === 'typing') {
return this.emit('typing', message)
}
super.receive(message)
}
sendMessage(msg: ChatMessage) {
return this.perform('speak', msg)
}
sendTyping() {
return this.perform('typing')
}
}
Now this typings information would help you to provide params or subscribe to events:
let channel: ChatChannel
channel = new ChatChannel({roomId: '2021'}) //=> OK
channel = new ChatChannel({room_id: '2021'}) //=> NOT OK: incorrect params key
channel = new ChatChannel() //=> NOT OK: missing params
channel.on('typing', (msg: TypingMessage) => {}) //=> OK
channel.on('typing', (msg: string) => {}) //=> NOT OK: 'msg' type mismatch
channel.on('types', (msg: TypingMessage) => {}) //=> NOT OK: unknown event
channel.perform('speak', {type: 'message', username: 'John', userId: '42'}) //=> OK
channel.perform('speak', {body: 'hello!'}) //=> NOT OK: incorrect message type
channel.perform('typing') //=> OK
channel.perform('typing', {type: 'typing', username: 'John'}) //=> NOT OK: no payload expected
channel.perform('type') //=> NOT OK: unknown action
By default, when you call createCable() we use the actioncable-v1-json protocol (supported by Action Cable).
You can also use Msgpack and Protobuf (soon) protocols supported by [AnyCable Pro][pro]:
// cable.js
import { createCable } from '@anycable/web'
import { MsgpackEncoder } from '@anycable/msgpack-encoder'
export default createCable({protocol: 'actioncable-v1-msgpack', encoder: new MsgpackEncoder()})
// or for protobuf
import { createCable } from '@anycable/web'
import { ProtobufEncoder } from '@anycable/protobuf-encoder'
export default createCable({protocol: 'actioncable-v1-protobuf', encoder: new ProtobufEncoder()})
NOTE: You MUST install the corresponding encoder package yourself, e.g., yarn add @anycable/msgpack-encoder or yarn add @anycable/protobuf-encoder.
AnyCable client also supports an extended version of the Action Cable protocol (actioncable-v1-ext-json) implemented by AnyCable server (v1.4+).
This version provides additional functionality to improve data consistency:
The features are implemented by the protocol itself, no need to update any existing channels code. All you need is to specify the protocol version when creating a client:
import { createCable } from '@anycable/web'
// or for non-web projects
// import { createCable } from '@anycable/core'
export default createCable({protocol: 'actioncable-v1-ext-json'})
You can use the extended protocol with Protobuf and Msgpack encoders as follows:
// cable.js
import { createCable } from '@anycable/web'
import { MsgpackEncoder } from '@anycable/msgpack-encoder'
export default createCable({protocol: 'actioncable-v1-ext-msgpack', encoder: new MsgpackEncoder()})
// or for protobuf
import { createCable } from '@anycable/web'
import { ProtobufEncoderV2 } from '@anycable/protobuf-encoder'
export default createCable({protocol: 'actioncable-v1-ext-protobuf', encoder: new ProtobufEncoderV2()})
To catch up messages broadcasted during the initial page load (or client-side application initialization), you can specify the historyTimestamp option to retrieve messages after the specified time along with subscription requests. The value must be a UTC timestamp (the number of seconds). For example:
```js expo
$ claude mcp add anycable-client \
-- python -m otcore.mcp_server <graph>