Introducing JavaScript and TypeScript client for AnyCable
AnyCable has been focusing on server-side performance for the last five years. However, all real-time cable applications consist of two parts: servers and clients. And today, we will concentrate on the the frontend: let me introduce the AnyCable JavaScript SDK.
How did we survive without writing a line of JavaScript code in our libraries? From day one, we bet on the compatibility with Action Cable, including the official @rails/actioncable
npm package. That was one of our selling points: no client-side changes were required to switch from Action Cable to AnyCable.
Unlike AnyCable, its Rails counterpart has barely evolved since 2015. The most significant change to the client-side library was a rewrite from CoffeeScript to modern JavaScript. Did we reach perfection? I doubt so. (And I don’t believe it’s even possible keeping in mind the everchanging nature of the frontend ecosystem)
I’ve been pondering the idea of crafting an alternative client implementation that would better suit AnyCable’s needs for a while. I even teased some imaginary APIs in the v1.0 announcement post. And now I’m ready to reveal the full picture. Here’s a little table of contents to help you navigate the article:
They all have motives
So, what encouraged me to reinvent the wheel to build the AnyCable client library?
The official library was designed with one use case in mind: Basecamp. It fully satisfies the project’s needs (I guess). And it lacks extensibility. How can I use a different transport? How can I change the reconnection strategy? What about supporting other serialization formats? The more I worked on new AnyCable features, the more often I asked myself these questions, and the only answer was monkey-patching.
I took pen and paper (literally, see below) and started thinking about the perfect architecture to solve all the current and potential problems.
My main goal was to abstract away the concept of a Channel. Channels should be pure logical abstractions knowing nothing about the underlying communication mechanisms.
The communication stack in its turn could be split into Transport (how do we send and receive bytes), Encoder (how do we serialize messages), and Protocol (how do we build messages, their schema). The underlying implementation of any of these three could be changed without breaking the other parts (unless the interfaces are satisfied).
Such design opens plenty of new opportunities: from using binary serialization formats (like we have in AnyCable Pro) to a switch to a new protocol (say, Action Cable v2) without changing the business logic (channels). We can even add long-polling support by implementing the correspondent transport (yes, WebSockets still don’t work for everyone).
Furthermore, we can easily support different platforms, such as Node.js and React Native.
Extensibility and interoperability were not the only reasons to build AnyCable JS SDK. I also wanted to improve the developer experience. One way of doing this is to provide typings.
Types, types, types
Adding static typing to dynamic languages has become extremely popular in recent years (so popular that we now have types for Ruby!). TypeScript is the second fastest-growing language (according to the recent JetBrains survey)! It was obvious that in 2021 no new JS library should exist without TS support.
Since I haven’t been seriously programming in JS for a few years, I decided not to go all-in on TypeScript and wrote the source code using plain JavaScript with *.d.ts
files next to .js
ones. That works for me well since I want to make writing code that uses the library easier, not the library itself.
And I think I reached the goal: thanks to the expressiveness of TypeScript (which amazed me), we can introduce additional strictness to channel definitions.
For example, we can define the required (and acceptable) parameters and the format of incoming messages:
import { Channel } 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;
export class ChatChannel extends Channel<Params, Message> {
static identifier = "ChatChannel";
}
// Without parameters, it would raise a type error and won't compile
let channel = new ChatChannel({ roomId: "2021" });
channel.on("message", (msg) => {
// Here compiler knows the type of the msg
if (msg.type === "typing") {
// Now, compiler knows that msg is a TypingMessage and not ChatMessage
}
});
I found these tiny additions to be very beneficial in terms of development experience (or, maybe, I was just too excited about TypeScript).
Connect, reconnect, and a bit of Mathematica
I mentioned in the beginning that the Rails Action Cable library gets almost no new features. That’s true, but that doesn’t mean the bugs are not getting fixed.
One particular Action Cable problem I heard from many developers (including DHH) is the “thundering herd” during application restart (also known as the connection avalanche): when a server restarts, all connections are getting closed, and then clients try to reconnect (that’s the responsibility of the client library). And if all the clients try to reconnect at about the same time, that could lead to application load spikes and even crashes.
Prior to Rails 7, the official Action Cable client did exactly what we have just described: made clients reconnect in a deterministic time (thus, almost simultaneously).
Thankfully, the problem was solved: now the client uses exponential backoff with jitter.
AnyCable partially solves this problem on the server-side: you don’t need to restart the WebSocket server during deployments; clients are kept connected.
Anyway, since AnyCable JS targets both AnyCable and Action Cable applications, we need to implement some smart reconnection mechanism. And by “smart,” I mean proven to be efficient. How can we prove it? By using some math, of course, or even Mathematica!
For comparison, I took three implementations (Rails 5, Rails 7, and Logux) and added a custom one based on the ideas from this AWS blog post. To better understand the difference, I decided to create an interactive visualization. The charts below show the distribution of reconnection delays for consecutive attempts, i.e., the amount of time from the connection loss till the Nth attempt.
Now you can clearly see the problem with the older Rails approach: reconnections occur within the same timeframe. AnyCable and Rails 7 versions look pretty similar, though I tried to increase the spread by adding an additional random deviation. The final formulae looks like this (welcome to the world of academical programming languages):
anyBackoff[attempt_, minDelay_, backoffRate_, jitterWeight_, maxDelay_] := Module[
{fun},
fun = Function[
{a, md, br, jw, mx},
left := 2 * md * (br^a);
right := 2 * md * (br^(a + 1));
t := Min[mx, left + (right - left) * Random[Real, 1.0]];
dv := 2 *(Random[Real, 1.0] - 0.5) * jw;
d := t * (1 + dv);
If[a === 0, d, fun[a -1, md, br, jw, mx] + d]
];
fun[attempt, minDelay, backoffRate, jitterWeight, maxDelay]
]
The jitterWeight
is responsible for adding additional randomness and is meant to be configurable depending on your needs. When it’s set to 0, the function behaves almost the same as the exponential backoff from Rails 7. When it equals 1, we get Full Jitter from the AWS post.
Features and futures
Currently, the AnyCable JS client provides full compatibility with Action Cable JSON protocol. That means you can start using it with Rails Action Cable today, no need to migrate to AnyCable. And you can do that by changing just a single line of code, thanks to the Action Cable compatibility mode:
- import { createConsumer } from "@rails/actioncable";
+ import { createConsumer } from "@anycable/web";
// createConsumer accepts all the options available to createCable
export default createConsumer();
It also includes the built-in support for AnyCable Pro features, such as Msgpack and Protobuf serialization:
// cable.js
import { createCable } from "@anycable/web";
import { MsgpackEncoder } from "@anycable/msgpack-encoder";
export default createCable({
protocol: "actioncable-v1-msgpack",
encoder: new MsgpackEncoder(),
});
As the library grows, we plan to further improve the developer experience (better logging, instrumentation, etc.) as well as add new features. For example, a CrossTabSocket transport: an ability to share a WebSocket between multiple tabs of the same browser to reduce the number of connections.
Finally, having our own client library is the preliminary step to start working on AnyCable protocol, a revised version of actioncable-v1-json
.
I hope this short introduction was sufficient to demo the benefits of having a hand-rolled Action Cable-compatible frontend library for use with AnyCable or just plain vanilla Rails. It also open the doors to an even more performant and developer friendly AnyCable Pro. Head to AnyCable’s website to learn more about the Pro version and remember we still offer free early access to the commercial features at least till the end of summer!
And, of course, feel free to dig into AnyCable JS code on GitHub: it’s free, open source, and will always remain this way.