action_cable.cr
action_cable.cr
A 1:1 Crystal port of actioncable 8.1.4 (Rails' Action Cable) under the same ActionCable namespace. WebSocket layer is fiber-per-connection on top of the Crystal stdlib HTTP::WebSocket (no nio4r + thread pool), speaking the exact Rails JSON protocol (welcome / subscription_confirmation / ping / disconnect, commands subscribe/message/unsubscribe) — the official @rails/actioncable JS client works unchanged. Pubsub adapters: Async (in-process, default), PostgreSQL LISTEN/NOTIFY, Redis pubsub. The hot path carries a Bun/uWS-style fast broadcast (encode-once envelopes, per-connection cork buffers, 1 write per batch, 1 MiB backpressure cap): 2.72M msg/s fan-out vs 2.52M for bun-publish. Suite: 51/51, including 33 oracle cases recorded with the real actioncable 8.1.4.
⚠️ EXPERIMENTAL FRAMEWORK, DO NOT USE IN PRODUCTION YET.
Installation
dependencies:
action_cable_cr:
github: OrelSokolov/action_cable.cr
shards install
Depends on active_support.cr, active_record.cr, action_pack.cr and the pg / redis shards.
Usage
require "action_cable_cr"
class ActionCable::Connection
identified_by :current_user
def connect
reject_unauthorized_connection! unless session["warden.user.user.key"]?
self.current_user = session["user_gid"]
end
end
class ChatChannel < ActionCable::Channel::Base
def subscribed
stream_from "chat_#{params["room"]}"
end
def speak(data) # public method = client RPC action
ActionCable.server.broadcast("chat_#{params["room"]}", data)
end
periodically :transmit_presence, every: 30
end
server = ActionCable::Server.new # Async adapter by default
HTTP::Server.new([server.ws_handler]).listen(3000) # ws://localhost:3000/cable
Cross-process setups swap the adapter (SubscriptionAdapter::Redis.new(url) or SubscriptionAdapter::PostgreSQL.new(url)); remote disconnect:
ActionCable.server.remote_connections.where(current_user: "1").disconnect(reconnect: true)
Feature comparison vs actioncable 8.1.4
Connection & protocol
| Feature | Original (actioncable) | Port (action_cable.cr) | Status |
|---|---|---|---|
Rails JSON wire protocol, @rails/actioncable JS client compat |
✅ | Same protocol end-to-end (verified by WS integration spec) | ✅ |
identified_by / connection_identifier (sorted gid join) |
✅ | Macro registry; String values only | ⚠️ partial |
connect / disconnect hooks, reject_unauthorized_connection (unauthorized disconnect frame) |
✅ | Same | ✅ |
Cookie auth (cookies) + verified signed session in connect |
✅ | request_cookies + Phase-C signed session (via action_pack.cr) |
✅ |
before_command / after_command / around_command |
✅ | Not implemented (P2) | ❌ |
Remote disconnect (RemoteConnections.where(...).disconnect) |
✅ | Same internal-channel design, cross-process on Redis/PG | ✅ |
Channel DSL
| Feature | Original | Port (action_cable.cr) | Status |
|---|---|---|---|
subscribed / unsubscribed, params, reject, transmit |
✅ | Same | ✅ |
perform_action dispatch to public methods |
✅ | Compile-time macro dispatch table | ✅ |
stream_from (with/without callback block), stop_stream_from, stop_all_streams |
✅ | Same; coder: option missing |
⚠️ partial |
stream_for / stop_stream_for / stream_or_reject_for |
✅ | Same, GlobalID-style gid naming | ✅ |
broadcast_to / broadcasting_for |
✅ | Class & instance level | ✅ |
periodically (PeriodicTimers) |
✅ | Method name or block, every: span/seconds |
✅ |
before_subscribe/after_subscribe/before_unsubscribe/after_unsubscribe hooks |
✅ | Used internally (periodic timers) only, not user-facing | ❌ |
Server, pubsub & performance
| Feature | Original | Port (action_cable.cr) | Status |
|---|---|---|---|
Mount at /cable, ActionCable.server.broadcast |
✅ | Server#ws_handler HTTP handler |
✅ |
| Pubsub adapters | async, PostgreSQL, Redis, (Solid Cable in Rails) | Async, PostgreSQL (LISTEN/NOTIFY), Redis | ✅ |
config/cable.yml, channel_prefix, allowed_request_origins, worker_pool_size, log_tags |
✅ | Not implemented | ❌ |
| Fast broadcast (encode-once, cork buffers, backpressure) | n/a | 2.72M msg/s ≈ bun-publish | ✅ |
| Server stats, health-check endpoint | ✅ | Not implemented (P2) | ❌ |
| Buffered WS reads, JSON fast-path on hot path | n/a | Backlog (P1.6) | ❌ |
Deviations & Crystal-specific notes
transmitis public (private in Ruby; Crystal has nosend);streamsbecame publicstream_namesfor testing.unsubscribe(channel, callback)→unsubscribe(channel): Crystal Procs have no identity, so it drops in-process callbacks but not the underlying Redis subscription.- Identifier values are Strings only (gid strings);
paramsisHash(String, String)instead ofHashWithIndifferentAccess;INTERNALis named constants instead of a heterogeneous hash. periodicallyis a macro DSL (a Proc is unspellable in a class body);every:acceptsTime::Spanor seconds,ArgumentErroron ≤ 0.- The gid app name comes from
ActionCable.gid_app(ENVCRAILS_APP_NAME, default "app"), notGlobalID.app. broadcastreturns nil, not a PUBLISH recipient count; PG NOTIFY payloads are capped at ~8000 bytes.
Full list with rationale: PLAN.md (Russian).
Testing
crystal spec # stream_for/pubsub suites need a local PostgreSQL
# (db crails_ac_test) and a local Redis, else they fail
Oracle: 33 cases (20 channel/protocol + 5 pubsub + 8 stream_for) recorded with the real actioncable 8.1.4 (oracle/cases.rb, oracle/pubsub_cases.rb, oracle/stream_for_cases.rb → expected*.yml); the Crystal specs replay them 1:1 without edits. The pubsub cases mirror the Redis adapter's behavior against both the Redis and PostgreSQL adapters. Full suite: 51/51.
action_cable.cr
- 0
- 0
- 0
- 0
- 2
- about 5 hours ago
- October 12, 2026
Mon, 12 Oct 2026 06:14:33 GMT