actioncable

package module
v0.0.0-...-4929795 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 17, 2026 License: BSD-3-Clause Imports: 15 Imported by: 0

README

go-ruby-actioncable/actioncable

actioncable — go-ruby-actioncable

Docs License Go Coverage

A pure-Go (no cgo) reimplementation of the core of Rails' Action Cable — the Channel / Connection / Subscription protocol machinery and pub-sub broadcasting from MRI/Rails 4.0.5 — faithful to the on-the-wire JSON sub-protocol, so a real Action Cable JavaScript client interoperates with it unchanged. It models the wire envelopes, the per-connection subscription registry, the channel lifecycle hooks, stream subscriptions and broadcasting fan-out without any Ruby runtime.

It is the Action Cable backend for go-embedded-ruby, but is a standalone, reusable module — a sibling of go-ruby-set, go-ruby-redis and go-ruby-activesupport.

The host-owned seams

Everything that is not protocol logic is left as an injectable seam, so the host (the rbgo binding) owns it:

Seam Type What the host plugs in
Transport func(payload []byte) the WebSocket write — a Connection transmits a ready-to-send JSON frame through it. A real pure-Go WebSocket transport (Handler / Upgrade) ships here as the concrete fill; the host may still inject its own
ConnectHook func(*Connection) error the connection's connect method — identification (identified_by) and authorization; return RejectUnauthorized to reject
ChannelAction func(channel, action string, data any) any the channel's Ruby method bodies — subscribed / unsubscribed / receive / custom actions. The binding builds the closure capturing the *Channel, so a body can call StreamFrom / Reject / Transmit on it
Adapter interface{ Broadcast; Subscribe } the pub-sub backend — AsyncAdapter (in-process) and RedisAdapter ship here

Wire-protocol fidelity

The JSON envelopes are byte-faithful to Action Cable's actioncable-v1-json protocol. Key order matters and is fixed per frame (dedicated structs), and HTML-significant bytes are escaped exactly as ActiveSupport::JSON does under its default escape_html_entities_in_json = true<, >, & become <, >, & and the JS line separators U+2028 / U+2029 become / — the mode a real Rails Action Cable server runs under. Every frame below and every name derivation is checked byte-for-byte against the real MRI actioncable gem by the differential oracle test:

Direction Frame Bytes
S→C welcome {"type":"welcome"}
S→C ping {"type":"ping","message":1751800000}
C→S subscribe {"command":"subscribe","identifier":"{\"channel\":\"ChatChannel\"}"}
S→C confirm {"identifier":"…","type":"confirm_subscription"}
S→C reject {"identifier":"…","type":"reject_subscription"}
C→S message {"command":"message","identifier":"…","data":"{\"action\":\"speak\"}"}
S→C message {"identifier":"…","message":{…}}
S→C disconnect {"type":"disconnect","reason":"remote","reconnect":true}

The type / command / reason strings mirror ActionCable::INTERNAL exactly.

Install

go get github.com/go-ruby-actioncable/actioncable

Usage

package main

import (
	"fmt"

	actioncable "github.com/go-ruby-actioncable/actioncable"
)

func main() {
	server := actioncable.NewServer(actioncable.NewAsyncAdapter())

	// The host owns the WebSocket; here we just print the frames.
	transport := func(payload []byte) { fmt.Println(string(payload)) }

	// The factory maps an identifier's "channel" to a Channel + its action body.
	factory := func(conn *actioncable.Connection, id string, p map[string]any) (*actioncable.Channel, bool) {
		var ch *actioncable.Channel
		action := func(_, act string, data any) any {
			switch act {
			case "subscribed":
				ch.StreamFrom("chat:1") // stream_from "chat:1"
			case "speak":
				ch.BroadcastTo("1", data) // broadcast_to room "1" -> "chat:1"
			}
			return nil
		}
		ch = actioncable.NewChannel(conn, actioncable.ChannelName(p["channel"].(string)), id, p, action)
		return ch, true
	}

	conn := actioncable.NewConnection(server, transport, factory)
	conn.Connect() // -> {"type":"welcome"}

	conn.Dispatch([]byte(`{"command":"subscribe","identifier":"{\"channel\":\"ChatChannel\"}"}`))
	// -> {"identifier":"{\"channel\":\"ChatChannel\"}","type":"confirm_subscription"}

	server.Broadcast("chat:1", map[string]any{"text": "hello"})
	// -> {"identifier":"{\"channel\":\"ChatChannel\"}","message":{"text":"hello"}}
}

Connection authorization

The connection's connect method — where identified_by and authorization live — is the ConnectHook seam, installed with OnConnect. It runs once, at Connect, before the welcome frame. Returning RejectUnauthorized() (the analogue of reject_unauthorized_connection raising UnauthorizedError) closes the connection with the unauthorized reason and reconnect:false and sends no welcome — exactly as the gem's handle_open rescue does:

conn := actioncable.NewConnection(server, transport, factory).
	OnConnect(func(c *actioncable.Connection) error {
		user := authenticate(c) // your cookie / token logic
		if user == "" {
			return c.RejectUnauthorized()
		}
		c.IdentifiedBy("current_user", user) // identified_by :current_user
		return nil
	})

err := conn.Connect()
// authorized:   -> {"type":"welcome"}                                        (err == nil)
// unauthorized: -> {"type":"disconnect","reason":"unauthorized","reconnect":false}
//                  errors.Is(err, actioncable.ErrUnauthorized) == true

WebSocket transport

A real, pure-Go (no cgo, no third-party dependency) WebSocket transport ships for the /cable endpoint: Handler performs the RFC 6455 server handshake, negotiates the actioncable-v1-json sub-protocol, and drives a Connection — a real Action Cable JavaScript client interoperates with it unchanged. It is the concrete fill for the Transport seam; a host wanting its own may keep using NewConnection with a custom Transport.

mux := http.NewServeMux()
mux.Handle("/cable", actioncable.Handler(server, factory, &actioncable.CableOptions{
	OnConnect:    authorize,          // the ConnectHook above
	PingInterval: 3 * time.Second,    // protocol-level heartbeat
}))
http.ListenAndServe(":28080", mux)

Upgrade(w, r) (*WSConn, error) is exposed for hosts that want the handshake without the drive loop. Only the subset Action Cable needs is implemented (unfragmented text frames, ping/pong, close); client frames are required to be masked and server frames are sent unmasked, per RFC 6455.

Pub-sub adapters

Server.Broadcast(broadcasting, data) JSON-encodes data and fans it out to every stream subscribed to that broadcasting. Two adapters satisfy the Adapter interface:

  • AsyncAdapter — in-process (the :async adapter). Fan-out is synchronous and deterministic; it leaks no goroutines.
  • RedisAdapter — cross-process (the :redis adapter). It carries no Redis code itself: it forwards to an injected RedisPubSub client seam (Publish / Subscribe), which go-ruby-redis satisfies. This keeps the module dependency-free and the adapter unit-testable with an in-memory fake.

broadcasting names are built by BroadcastingName(channelName, model) / SerializeBroadcasting(...), faithful to serialize_broadcasting (GlobalID to_gid_paramto_param → string, joined by :), and ChannelName derives the channel_name component (ChatChannelchat, Chat::RoomChannelchat:room).

API surface (v0.1)

// Protocol
func EncodeWelcome() []byte
func EncodePing(epoch int64) []byte
func EncodeConfirmation(identifier string) []byte
func EncodeRejection(identifier string) []byte
func EncodeMessage(identifier string, message any) ([]byte, error)
func EncodeDisconnect(reason string, reconnect bool) []byte
func DecodeCommand(raw []byte) (Command, error)

// Pub-sub
type Adapter interface { Broadcast(...); Subscribe(...) }
type RedisPubSub interface { Publish(...); Subscribe(...) }
func NewAsyncAdapter() *AsyncAdapter
func NewRedisAdapter(client RedisPubSub) *RedisAdapter
func NewServer(adapter Adapter) *Server
func (s *Server) Broadcast(broadcasting string, message any) error
func (s *Server) RemoteConnections() *RemoteConnections

// Connection
type Transport func(payload []byte)
type ChannelFactory func(*Connection, string, map[string]any) (*Channel, bool)
type ConnectHook func(*Connection) error
var ErrUnauthorized error
func NewConnection(server *Server, transport Transport, factory ChannelFactory) *Connection
func (c *Connection) OnConnect(hook ConnectHook) *Connection  // connect method seam
func (c *Connection) RejectUnauthorized() error              // reject_unauthorized_connection
func (c *Connection) Connect() error                         // connect hook + welcome
func (c *Connection) Dispatch(raw []byte) error              // subscribe/unsubscribe/message
func (c *Connection) Beat(epoch int64)                       // ping
func (c *Connection) Advance(d time.Duration)                // fire due periodic timers
func (c *Connection) Disconnect(reason string, reconnect bool)
func (c *Connection) IdentifiedBy(key string, value any)     // identified_by

// WebSocket transport (pure-Go RFC 6455, /cable)
type CableOptions struct { OnConnect ConnectHook; PingInterval time.Duration }
func Handler(server *Server, factory ChannelFactory, opts *CableOptions) http.Handler
func Upgrade(w http.ResponseWriter, r *http.Request) (*WSConn, error)

// Channel
type ChannelAction func(channel, action string, data any) any
func NewChannel(conn *Connection, name, identifier string, params map[string]any, action ChannelAction) *Channel
func (ch *Channel) StreamFrom(broadcasting string) error
func (ch *Channel) StreamFor(model any) error
func (ch *Channel) BroadcastTo(model, message any) error
func (ch *Channel) Transmit(data any) error
func (ch *Channel) Reject()
func (ch *Channel) Periodically(interval time.Duration, fn func()) *PeriodicTimer

// Broadcasting names
func BroadcastingName(channelName string, model any) string
func SerializeBroadcasting(objects ...any) string
func ChannelName(className string) string

Oracle: differential test vs the gem

testdata/oracle.rb runs the real MRI actioncable gem and emits the exact bytes it produces for every wire frame and every name derivation; oracle_test.go computes each case through the Go API and asserts byte-for-byte equality, so the gem is the ground truth for the on-the-wire protocol. The oracle skips itself where ruby or the gem is absent (Windows, qemu, no-gem machines), and the deterministic ruby-free tests alone keep coverage at 100% on those lanes. Running it against actioncable 8.0.2 is what pinned the HTML-entity escaping (ActiveSupport::JSON's default escape_html_entities_in_json = true) and the acronym-boundary channel_name rule (HTTPServerChannelhttp_server).

Roadmap (still deferred)

The protocol core, pub-sub, connection authorization and the /cable WebSocket transport are implemented. The channel bodies stay a seam by design. Deferred, in rough order:

  • Rails engine mount — routing, config.action_cable.*, cable.yml.
  • Request-origin / CSRF checksallow_request_origin?, disable_request_forgery_protection, cookie / Warden / Devise plumbing (the ConnectHook seam is where a host wires these today).
  • InstrumentationActiveSupport::Notifications events, logging tags.
  • Full RemoteConnections — beyond the internal-channel disconnect modeled here.
  • go-ruby-redis wiring — a ready-made RedisPubSub adapter over go-ruby-redis (the seam is in place; the concrete binding is deferred).
  • rbgo binding — expose Channel / Connection / ActionCable.server to pure-Go Ruby.

Tests & coverage

The suite is deterministic and Ruby-free: it asserts every wire envelope byte-for-byte, the subscribe → confirm / reject flow, stream subscription and broadcast fan-out, the async and redis adapters (the latter over an in-memory fake client), the channel hooks via fake ChannelAction seams, periodic timers on the virtual clock, and remote disconnect over the internal channel. It leaks no goroutines.

COVERPKG=$(go list ./... | paste -sd, -)
go test -race -coverpkg="$COVERPKG" -coverprofile=cover.out ./...
go tool cover -func=cover.out | tail -1   # 100.0%

CGO-free, dependency-free, gofmt + go vet clean, race-clean, and green across the six 64-bit Go targets (amd64, arm64, riscv64, loong64, ppc64le, s390x — the big-endian s390x lane exercises the codec) and three OSes (Linux, macOS, Windows).

License

BSD-3-Clause — see LICENSE. Copyright the go-ruby-actioncable/actioncable authors.

WebAssembly

Being pure Go (CGO=0), this library also compiles to WebAssembly — both GOOS=js GOARCH=wasm (browser / Node.js) and GOOS=wasip1 GOARCH=wasm (WASI). CI builds both targets on every push, alongside the six 64-bit native/qemu arches.

GOOS=js     GOARCH=wasm go build ./...   # browser / Node
GOOS=wasip1 GOARCH=wasm go build ./...   # WASI (wasmtime, wasmer, wasmedge, …)

Documentation

Overview

Package actioncable is a pure-Go (no cgo) reimplementation of the core of Rails' Action Cable, faithful to MRI/Rails 4.0.5's on-the-wire protocol.

It models the WebSocket sub-protocol (welcome/ping/subscribe/confirm/reject/ unsubscribe/message/disconnect envelopes), the per-connection subscription registry, the channel lifecycle hooks, stream subscriptions and pub-sub broadcasting fan-out — everything that makes a real Action Cable JavaScript client interoperate — while leaving the two host-owned concerns as seams:

The pub-sub backend is an Adapter; an in-process AsyncAdapter and a RedisAdapter (over a RedisPubSub client seam) ship here.

The package is dependency-free, CGO-free, and has no dependency on any Ruby runtime. It is the Action Cable backend for go-embedded-ruby, but is a standalone, reusable module.

Index

Constants

View Source
const (
	TypeWelcome      = "welcome"
	TypeDisconnect   = "disconnect"
	TypePing         = "ping"
	TypeConfirmation = "confirm_subscription"
	TypeRejection    = "reject_subscription"
)

Message type strings the server sends to clients. These mirror ActionCable::INTERNAL[:message_types] exactly.

View Source
const (
	CommandSubscribe   = "subscribe"
	CommandUnsubscribe = "unsubscribe"
	CommandMessage     = "message"
)

Command strings the client sends to the server. These mirror the commands accepted by ActionCable::Connection::Subscriptions.

View Source
const (
	DisconnectUnauthorized   = "unauthorized"
	DisconnectInvalidRequest = "invalid_request"
	DisconnectServerRestart  = "server_restart"
	DisconnectRemote         = "remote"
)

Disconnect reasons, mirroring ActionCable::INTERNAL[:disconnect_reasons].

View Source
const DefaultMountPath = "/cable"

DefaultMountPath is Action Cable's default mount path.

Variables

View Source
var ErrUnauthorized = errors.New("actioncable: unauthorized connection")

ErrUnauthorized is the sentinel a ConnectHook returns — directly or via Connection.RejectUnauthorized — to reject a connection. It is the analogue of ActionCable::Connection::Authorization::UnauthorizedError, which reject_unauthorized_connection raises. When Connection.Connect sees it, the connection is closed with the "unauthorized" reason and reconnect=false and no welcome is sent, mirroring the gem's handle_open rescue.

View Source
var Protocols = []string{"actioncable-v1-json", "actioncable-unsupported"}

Protocols are the WebSocket sub-protocols Action Cable advertises, mirroring ActionCable::INTERNAL[:protocols]. The first is the JSON protocol this package implements.

Functions

func BroadcastingName

func BroadcastingName(channelName string, model any) string

BroadcastingName returns the stable broadcasting string for a channel and a model — the value ActionCable.server.broadcast fans out on. It mirrors ChannelClass.broadcasting_for(model) == serialize_broadcasting([channel_name, model]).

func ChannelName

func ChannelName(className string) string

ChannelName derives a channel's broadcasting-name component from its Ruby class name, mirroring ActionCable::Channel::Naming#channel_name: name.delete_suffix("Channel").gsub("::", ":").underscore.

"ChatChannel"                     -> "chat"
"Chat::RoomChannel"               -> "chat:room"
"AdminNotificationsChannel"       -> "admin_notifications"
"FooChats::BarAppearancesChannel" -> "foo_chats:bar_appearances"
"HTTPServerChannel"               -> "http_server"

func EncodeConfirmation

func EncodeConfirmation(identifier string) []byte

EncodeConfirmation returns {"identifier":<id>,"type":"confirm_subscription"}.

func EncodeDisconnect

func EncodeDisconnect(reason string, reconnect bool) []byte

EncodeDisconnect returns {"type":"disconnect","reason":<reason>,"reconnect":<b>}.

func EncodeMessage

func EncodeMessage(identifier string, message any) ([]byte, error)

EncodeMessage returns {"identifier":<id>,"message":<message>}. Because message is arbitrary host data it may fail to encode, so an error is returned.

func EncodePing

func EncodePing(epoch int64) []byte

EncodePing returns the {"type":"ping","message":<epoch>} heartbeat frame.

func EncodeRejection

func EncodeRejection(identifier string) []byte

EncodeRejection returns {"identifier":<id>,"type":"reject_subscription"}.

func EncodeWelcome

func EncodeWelcome() []byte

EncodeWelcome returns the {"type":"welcome"} frame sent on connect.

func Handler

func Handler(server *Server, factory ChannelFactory, opts *CableOptions) http.Handler

Handler returns an http.Handler for the /cable endpoint. It upgrades each request to WebSocket, builds a Connection whose Transport writes text frames to the socket and whose channels come from factory, runs the open handshake (connect hook + welcome), then pumps client frames into Connection.Dispatch until the socket closes. A failed handshake is answered with 400/426 and the socket is not driven. This is the concrete /cable transport; hosts wanting their own may keep using NewConnection with a custom Transport directly.

func SerializeBroadcasting

func SerializeBroadcasting(objects ...any) string

SerializeBroadcasting joins the serialized components with ":", mirroring ActionCable::Channel::Broadcasting#serialize_broadcasting for an Array.

Types

type Adapter

type Adapter interface {
	Broadcast(broadcasting string, payload []byte) error
	Subscribe(broadcasting string, handler func(payload []byte)) (unsubscribe func(), err error)
}

Adapter is the pub-sub backend Action Cable fans broadcasts out through. A broadcasting is an opaque string channel; payloads are the already-encoded JSON bytes produced by Server.Broadcast. Both AsyncAdapter and RedisAdapter satisfy it.

Subscribe registers handler for a broadcasting and returns a function that removes that subscription. Handlers are invoked with each broadcast payload.

type AsyncAdapter

type AsyncAdapter struct {
	// contains filtered or unexported fields
}

AsyncAdapter is the in-process pub-sub adapter — the analogue of Action Cable's :async adapter. Fan-out is synchronous and deterministic: Broadcast invokes every current subscriber's handler on the calling goroutine and returns when they have all run. It leaks no goroutines.

func NewAsyncAdapter

func NewAsyncAdapter() *AsyncAdapter

NewAsyncAdapter returns an empty in-process adapter.

func (*AsyncAdapter) Broadcast

func (a *AsyncAdapter) Broadcast(broadcasting string, payload []byte) error

Broadcast synchronously delivers payload to every current subscriber of broadcasting. Subscribers are snapshotted first so a handler may (un)subscribe during delivery without racing the iteration.

func (*AsyncAdapter) Subscribe

func (a *AsyncAdapter) Subscribe(broadcasting string, handler func([]byte)) (func(), error)

Subscribe registers handler for broadcasting.

type CableOptions

type CableOptions struct {
	// OnConnect is the connect method run at open for identification/authorization
	// (see [ConnectHook]); may be nil.
	OnConnect ConnectHook
	// PingInterval is the protocol-level ping period. Zero disables the heartbeat.
	// The application-level ping frame Action Cable clients expect is a separate,
	// host-driven concern; this keeps idle intermediaries from dropping the socket.
	PingInterval time.Duration
}

CableOptions configures Handler.

type Channel

type Channel struct {

	// Identifier is the raw subscription identifier JSON string.
	Identifier string
	// Params are the parsed identifier params (including "channel").
	Params map[string]any
	// Action runs the channel's Ruby bodies. May be nil (no-op hooks).
	Action ChannelAction
	// contains filtered or unexported fields
}

Channel is one subscription on a connection, the analogue of ActionCable::Channel::Base. It owns the streams it is subscribed to and its periodic timers.

func NewChannel

func NewChannel(conn *Connection, name, identifier string, params map[string]any, action ChannelAction) *Channel

NewChannel builds a channel bound to conn. name is its channel_name component (see ChannelName); identifier is the raw identifier string; params are its parsed params; action runs its bodies (may be nil).

func (*Channel) BroadcastTo

func (ch *Channel) BroadcastTo(model any, message any) error

BroadcastTo broadcasts message to the broadcasting for model, mirroring the class method Channel.broadcast_to(model, message).

func (*Channel) Confirmed

func (ch *Channel) Confirmed() bool

Confirmed reports whether the subscription was confirmed.

func (*Channel) Name

func (ch *Channel) Name() string

Name returns the channel_name component.

func (*Channel) Periodically

func (ch *Channel) Periodically(interval time.Duration, fn func()) *PeriodicTimer

Periodically registers fn to run once per interval on the connection's deterministic scheduler, mirroring Channel::PeriodicTimers#periodically.

func (*Channel) Reject

func (ch *Channel) Reject()

Reject rejects the subscription from within the subscribed hook, mirroring Channel::Base#reject.

func (*Channel) Rejected

func (ch *Channel) Rejected() bool

Rejected reports whether the subscription was rejected in its subscribed hook.

func (*Channel) StreamFor

func (ch *Channel) StreamFor(model any) error

StreamFor subscribes to the broadcasting for model, mirroring stream_for.

func (*Channel) StreamFrom

func (ch *Channel) StreamFrom(broadcasting string) error

StreamFrom subscribes the channel to broadcasting: every value broadcast there is decoded and transmitted to the client as a channel message, mirroring Channel::Streams#stream_from with the default handler. Subscribing to the same broadcasting twice is a no-op.

func (*Channel) Streams

func (ch *Channel) Streams() []string

Streams returns the broadcastings this channel is currently subscribed to.

func (*Channel) Transmit

func (ch *Channel) Transmit(data any) error

Transmit sends data to this subscription's client as a channel message frame {"identifier":...,"message":data}, mirroring Channel::Base#transmit.

type ChannelAction

type ChannelAction func(channel, action string, data any) any

ChannelAction is the seam that runs a channel's Ruby method bodies. The host (the rbgo binding) supplies it, capturing the *Channel in a closure so the body can call the channel's own methods (StreamFrom, Reject, Transmit, ...). The library invokes it as action(channelName, action, data):

  • action "subscribed" when the subscription is confirmed (data is nil);
  • action "unsubscribed" when it is torn down (data is nil);
  • action "receive" or a custom action name for an inbound "message" command, with data being the decoded message payload.

Its return value is the Ruby method's result (unused by the library itself).

type ChannelFactory

type ChannelFactory func(conn *Connection, identifier string, params map[string]any) (*Channel, bool)

ChannelFactory builds the Channel for a subscribe command. It receives the raw identifier string and its parsed params (which include "channel", the Ruby channel class name). It returns the channel and true, or false if no channel class matches the identifier — in which case the subscription is not confirmed, mirroring Rails logging "Subscription class not found".

type Command

type Command struct {
	Command    string `json:"command"`
	Identifier string `json:"identifier"`
	Data       string `json:"data"`
}

Command is a client-to-server frame: {"command":...,"identifier":...,"data":...}. identifier is itself a JSON string (e.g. `{"channel":"ChatChannel"}`); data, present only for the "message" command, is a JSON string carrying the action payload.

func DecodeCommand

func DecodeCommand(raw []byte) (Command, error)

DecodeCommand parses a client-to-server frame.

type ConnectHook

type ConnectHook func(*Connection) error

ConnectHook is the seam for a connection's app-defined connect method (ActionCable::Connection::Base#connect), where identification and authorization happen. It runs once, in Connection.Connect, before the welcome frame. It typically sets identified-by values via Connection.IdentifiedBy and may reject the connection by returning Connection.RejectUnauthorized (or any error wrapping ErrUnauthorized). May be nil (no connect method).

type Connection

type Connection struct {

	// Scheduler holds this connection's (and its channels') periodic timers,
	// advanced deterministically by the host's event loop.
	Scheduler *Scheduler
	// contains filtered or unexported fields
}

Connection is a single client connection: its identified-by keys, its subscription registry, and the transport it transmits through. It is the analogue of ActionCable::Connection::Base.

func NewConnection

func NewConnection(server *Server, transport Transport, factory ChannelFactory) *Connection

NewConnection creates a connection that transmits through transport and builds channels through factory.

func (*Connection) Advance

func (c *Connection) Advance(d time.Duration)

Advance moves this connection's timer scheduler forward by d, firing any due channel periodic timers.

func (*Connection) Beat

func (c *Connection) Beat(epoch int64)

Beat transmits a heartbeat ping frame carrying epoch, mirroring the periodic ping Action Cable sends. The host drives it on its own schedule.

func (*Connection) Closed

func (c *Connection) Closed() bool

Closed reports whether the connection has been closed.

func (*Connection) Connect

func (c *Connection) Connect() error

Connect performs the open handshake, mirroring the gem's handle_open: it runs the connect method (if any) for identification/authorization, subscribes to this connection's internal channel so RemoteConnections can reach it, then transmits the welcome frame.

If the connect hook rejects the connection by returning ErrUnauthorized (e.g. via Connection.RejectUnauthorized), Connect closes the connection with the "unauthorized" reason and reconnect=false, sends no welcome, and returns the error — the analogue of handle_open rescuing UnauthorizedError. Any other error from the hook is returned without sending a welcome and without closing (the host decides), leaving the transport untouched.

func (*Connection) Disconnect

func (c *Connection) Disconnect(reason string, reconnect bool)

Disconnect closes the connection, transmitting a disconnect frame with reason and telling the client whether to reconnect.

func (*Connection) Dispatch

func (c *Connection) Dispatch(raw []byte) error

Dispatch routes a single client-to-server frame to the matching handler.

func (*Connection) IdentifiedBy

func (c *Connection) IdentifiedBy(key string, value any)

IdentifiedBy sets an identified-by key (e.g. current_user) for the connection.

func (*Connection) Identifier

func (c *Connection) Identifier(key string) any

Identifier returns a previously set identified-by value.

func (*Connection) OnConnect

func (c *Connection) OnConnect(hook ConnectHook) *Connection

OnConnect installs the connection's connect method (see ConnectHook), returning the connection so it can be chained after NewConnection. It mirrors defining #connect on an ActionCable::Connection::Base subclass.

func (*Connection) RejectUnauthorized

func (c *Connection) RejectUnauthorized() error

RejectUnauthorized rejects the connection, mirroring reject_unauthorized_connection: it returns ErrUnauthorized, which a ConnectHook returns to make Connection.Connect close the connection with the "unauthorized" reason and reconnect=false without sending a welcome frame.

func (*Connection) Subscription

func (c *Connection) Subscription(identifier string) (*Channel, bool)

Subscription returns the channel for identifier, if subscribed.

func (*Connection) Subscriptions

func (c *Connection) Subscriptions() int

Subscriptions returns the number of active subscriptions (mostly for tests).

type GIDParameterizer

type GIDParameterizer interface {
	ToGIDParam() string
}

GIDParameterizer is satisfied by a model that exposes a GlobalID param, preferred over ToParam when serializing (mirrors object.to_gid_param).

type Parameterizer

type Parameterizer interface {
	ToParam() string
}

Parameterizer is satisfied by a model that knows its own #to_param, used when serializing a broadcasting name (mirrors Rails' object.to_param).

type PeriodicTimer

type PeriodicTimer struct {
	// contains filtered or unexported fields
}

PeriodicTimer models a channel's periodic timer (Channel#periodically). It is driven by a deterministic virtual clock via Scheduler.Advance rather than by wall-clock goroutines, so tests are reproducible and nothing leaks.

type RedisAdapter

type RedisAdapter struct {
	// contains filtered or unexported fields
}

RedisAdapter is the pub-sub Adapter backed by Redis, the analogue of Action Cable's :redis adapter. It broadcasts across processes/servers by publishing on the Redis channel named after the broadcasting. It carries no Redis implementation itself: it forwards to an injected RedisPubSub client.

func NewRedisAdapter

func NewRedisAdapter(client RedisPubSub) *RedisAdapter

NewRedisAdapter returns a RedisAdapter forwarding to client.

func (*RedisAdapter) Broadcast

func (r *RedisAdapter) Broadcast(broadcasting string, payload []byte) error

Broadcast publishes payload on the Redis channel named broadcasting.

func (*RedisAdapter) Subscribe

func (r *RedisAdapter) Subscribe(broadcasting string, handler func([]byte)) (func(), error)

Subscribe subscribes handler to the Redis channel named broadcasting.

type RedisPubSub

type RedisPubSub interface {
	Publish(channel string, payload []byte) error
	Subscribe(channel string, handler func(payload []byte)) (unsubscribe func(), err error)
}

RedisPubSub is the minimal Redis pub-sub client seam the RedisAdapter drives. It is deliberately tiny so the host wires in a real client (e.g. go-ruby-redis) without this module taking a runtime dependency on it, and so the adapter is testable with an in-memory fake. Publish maps to Redis PUBLISH; Subscribe maps to SUBSCRIBE and returns an unsubscribe.

type RemoteConnection

type RemoteConnection struct {
	// contains filtered or unexported fields
}

RemoteConnection is a handle to the connection(s) matching a set of identifiers, addressed via their shared internal pub-sub channel.

func (*RemoteConnection) Disconnect

func (rc *RemoteConnection) Disconnect(reconnect bool) error

Disconnect asks the matching connection(s) to close, telling the client whether to reconnect. It publishes an internal disconnect message on the connection's internal channel; any live connection with that identity reacts by closing with the "remote" reason. This is the cross-server mechanism RemoteConnection#disconnect uses.

type RemoteConnections

type RemoteConnections struct {
	// contains filtered or unexported fields
}

RemoteConnections selects connections to act on by their identifiers.

func (*RemoteConnections) Where

func (rc *RemoteConnections) Where(identifiers map[string]any) *RemoteConnection

Where selects the connection(s) matching the given identified-by values, mirroring remote_connections.where(current_user: user).

type Scheduler

type Scheduler struct {
	// contains filtered or unexported fields
}

Scheduler is a deterministic virtual clock holding a channel's (and a connection's) periodic timers. Advancing it fires every timer that has come due; it never spawns a goroutine.

func (*Scheduler) Advance

func (s *Scheduler) Advance(d time.Duration)

Advance moves the virtual clock forward by d, firing each due timer once per whole interval elapsed.

func (*Scheduler) Every

func (s *Scheduler) Every(interval time.Duration, fn func()) *PeriodicTimer

Every registers fn to run once per interval and returns its handle. A non-positive interval registers an inert timer that never fires.

func (*Scheduler) Remove

func (s *Scheduler) Remove(t *PeriodicTimer)

Remove detaches a timer so it no longer fires.

type Server

type Server struct {
	// contains filtered or unexported fields
}

Server is the pub-sub hub, the analogue of ActionCable.server. It owns an Adapter and turns ActionCable.server.broadcast(broadcasting, data) into an encode-then-fan-out over that adapter.

func NewServer

func NewServer(adapter Adapter) *Server

NewServer returns a Server broadcasting through adapter.

func (*Server) Adapter

func (s *Server) Adapter() Adapter

Adapter returns the underlying pub-sub adapter.

func (*Server) Broadcast

func (s *Server) Broadcast(broadcasting string, message any) error

Broadcast JSON-encodes message and fans it out to every stream subscribed to broadcasting — the equivalent of ActionCable.server.broadcast. Subscribers receive the encoded payload; the default stream handler decodes it and transmits it to the client as a channel message.

func (*Server) RemoteConnections

func (s *Server) RemoteConnections() *RemoteConnections

RemoteConnections is the entry point for disconnecting connections identified elsewhere, the analogue of ActionCable.server.remote_connections.

type Transport

type Transport func(payload []byte)

Transport is the seam by which a Connection sends an encoded frame to the client. The host (e.g. the rbgo binding) owns the actual WebSocket and plugs its write here; the payload is a ready-to-send JSON frame.

type WSConn

type WSConn struct {

	// Subprotocol is the negotiated sub-protocol (e.g. "actioncable-v1-json").
	Subprotocol string
	// contains filtered or unexported fields
}

WSConn is a single upgraded WebSocket connection: the hijacked net.Conn plus a buffered reader and a write mutex (the read loop and the heartbeat both write).

func Upgrade

func Upgrade(w http.ResponseWriter, r *http.Request) (*WSConn, error)

Upgrade performs the RFC 6455 server handshake on r/w, negotiating one of the Action Cable Protocols, and returns the upgraded WSConn. It writes the 101 response itself. Errors: [errNotWebSocket] if r is not an upgrade request, [errBadSubprotocol] if the client offers no supported sub-protocol, or [errNoHijack] if w cannot be hijacked.

func (*WSConn) Close

func (c *WSConn) Close() error

Close sends a close frame (best effort) and closes the underlying socket.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL