wschannel

package
v0.8.1 Latest Latest
Warning

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

Go to latest
Published: May 27, 2026 License: MIT Imports: 13 Imported by: 0

Documentation

Overview

Package wschannel implements a minimal Phoenix Channels V2 client targeted at long-lived authenticated connections (TUI panes, daemons).

The wire format is the Phoenix V2 array form:

[join_ref, ref, topic, event, payload]

All five elements are present in every message. join_ref and ref may be JSON null. Topics, events, and payloads are arbitrary JSON.

This package is intentionally small — it does not depend on the Go "phx" community library so that we control reconnection behaviour and keep the dependency tree shallow. The codec is exposed for unit tests and for callers that want to drive a raw connection.

Index

Constants

View Source
const ReconnectedEvent = "rejoined"

Reconnected is a synthetic event the client emits over Pushes() each time it has successfully re-dialled the socket and re-joined a topic. Applications observe it to replay in-channel state (subscriptions, item watches) that the server doesn't remember across the restart.

Carried inside a Push as event "rejoined" with the welcome envelope in Payload, on the topic that was rejoined.

View Source
const ReconnectingEvent = "reconnecting"

Reconnecting is a synthetic event emitted before every dial attempt while the session is down. Payload carries `attempt` (1-indexed) and `next_attempt_at` (RFC3339-nano UTC). Applications can use this to drive countdown UIs and outage markers. Topic is empty (the event is not associated with any channel topic).

Variables

This section is empty.

Functions

This section is empty.

Types

type ChannelError

type ChannelError struct {
	Code    ChannelErrorCode
	Message string
}

ChannelError carries the typed error envelope from a Phoenix Channel `phx_reply` whose status was "error". The Code field is one of the ChannelErrCode* constants above; Message is the server-provided human-readable detail.

func (*ChannelError) Error

func (e *ChannelError) Error() string

type ChannelErrorCode

type ChannelErrorCode string

ChannelErrorCode is one of the typed error codes the server returns in a `phx_reply` with status="error". Defined as constants here so CLI callers can pattern-match on them without sprinkling string literals.

const (
	// ChannelErrCodeForbidden — the actor is not a member of the target
	// (used by scope.switch_team for non-member targets).
	ChannelErrCodeForbidden ChannelErrorCode = "forbidden"

	// ChannelErrCodeNotFound — the target resource (team, item, …) does
	// not exist.
	ChannelErrCodeNotFound ChannelErrorCode = "not_found"

	// ChannelErrCodeNoop — the requested change has no effect (e.g.
	// switching to the team already in scope). Treated as "success-
	// equivalent" by callers that just want to confirm a desired
	// state, but surfaced as an error for those that want to know.
	ChannelErrCodeNoop ChannelErrorCode = "noop"

	// ChannelErrCodeInvalid — the inbound payload was malformed.
	ChannelErrCodeInvalid ChannelErrorCode = "invalid"

	// ChannelErrCodeRateLimited — the channel's @cmd_limit gate fired.
	ChannelErrCodeRateLimited ChannelErrorCode = "rate_limited"
)

type Client

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

Client is a Phoenix Channel session multiplexing one or more topics over a single WebSocket. It maintains the connection, a heartbeat, ref → reply correlation, and a fan-out of server pushes from every joined topic.

Lifecycle:

  1. New(opts) returns a configured client (no I/O).
  2. Connect(ctx) opens the socket and joins the primary topic; returns the join reply.
  3. JoinTopic(ctx, topic) joins additional topics on the same socket.
  4. Push(ctx, topic, event, payload) sends a command and blocks for the matching phx_reply; safe to call concurrently from multiple goroutines, across any joined topic.
  5. Pushes() yields server-initiated events from every joined topic; consumers route on Push.Topic.
  6. Close() shuts everything down cleanly.

Reconnect-with-backoff is intentionally NOT in V1; the caller treats a closed Pushes() channel as an explicit signal to retry.

func New

func New(opts Options) (*Client, error)

New constructs a Client without performing any network I/O.

func (*Client) Close

func (c *Client) Close() error

Close shuts down the client and underlying socket cleanly. Safe to call multiple times.

func (*Client) Connect

func (c *Client) Connect(ctx context.Context) (json.RawMessage, error)

Connect opens the WebSocket, joins the primary channel topic, and installs a session loop that auto-reconnects on transport failure. Returns the initial join reply for the primary topic.

On reconnect the client re-dials, replays every previously-joined topic, and emits a synthetic `rejoined` push (event ReconnectedEvent) per topic so the application can resync any in-channel state the server doesn't remember (subscriptions, item watches).

func (*Client) JoinTopic

func (c *Client) JoinTopic(ctx context.Context, topic string) (json.RawMessage, error)

JoinTopic joins an additional channel topic on the existing socket. Returns the raw join reply payload. Safe to call after Connect.

func (*Client) Push

func (c *Client) Push(ctx context.Context, topic, event string, payload any) (PhxReply, error)

Push sends an event on the given (already-joined) topic and blocks until the matching phx_reply arrives, or ctx is cancelled.

func (*Client) Pushes

func (c *Client) Pushes() <-chan Push

Pushes returns the channel of server-initiated events from every joined topic. The channel is closed when the connection ends (clean Close() or a transport error).

func (*Client) Status

func (c *Client) Status() Status

Status returns the current connection lifecycle state. Cheap and race-safe; suitable for tight polling from external callers (UI status bar, --script mode readiness gate, integration tests).

func (*Client) SwitchTeam

func (c *Client) SwitchTeam(ctx context.Context, teamID string) (*SwitchTeamReply, error)

type Frame

type Frame struct {
	JoinRef *string         // optional; null in JSON when absent
	Ref     *string         // optional; null for server pushes
	Topic   string          // e.g. "console:lobby" or "phoenix" (heartbeat)
	Event   string          // e.g. "phx_join", "subscribe", "stream"
	Payload json.RawMessage // raw JSON object
}

Frame is a single Phoenix Channel message in V2 array form.

func (Frame) MarshalJSON

func (f Frame) MarshalJSON() ([]byte, error)

MarshalJSON encodes the frame as the canonical 5-element array.

func (*Frame) UnmarshalJSON

func (f *Frame) UnmarshalJSON(data []byte) error

UnmarshalJSON decodes a 5-element Phoenix array into a Frame.

type Options

type Options struct {
	// URL is the base WS endpoint, e.g. "wss://www.truestamp.com/console/websocket".
	// vsn=2.0.0 and api_key are appended automatically.
	URL string

	// APIKey is the Truestamp API key. Sent as a query parameter to the
	// Socket.connect/3 callback on the server.
	APIKey string

	// Topic is the primary channel topic Connect joins. Defaults to
	// "console:lobby" when empty. Additional topics can be joined later
	// via JoinTopic.
	Topic string

	// HeartbeatInterval — Phoenix's default is 30s; set lower for tests.
	// Zero or negative falls back to 30s.
	HeartbeatInterval time.Duration

	// PushBufferSize sets the capacity of the Pushes channel. Defaults to 256.
	PushBufferSize int

	// Logger receives transport diagnostics (read EOFs, dial failures,
	// frame decode errors, push channel overflow). When nil, logs are
	// discarded. The TUI is the typical caller and should pass a
	// file-backed logger from internal/logging — these messages are
	// noisy by design and would clutter the UI.
	Logger *slog.Logger
}

Options configures a new Client.

type PhxReply

type PhxReply struct {
	Status   string          `json:"status"`
	Response json.RawMessage `json:"response"`
}

PhxReply is the payload shape Phoenix sends back for messages that have a ref — i.e. anything that expects a reply.

{"status":"ok","response":{...}}    or    {"status":"error","response":{...}}

func ParseReply

func ParseReply(f Frame) (PhxReply, error)

ParseReply extracts the PhxReply from a "phx_reply" frame.

type Push

type Push struct {
	Topic   string          // e.g. "console:lobby" or "console:clock"
	Event   string          // e.g. "stream", "tick", "error"
	Payload json.RawMessage // raw JSON object
}

Push is a server-initiated channel event delivered to the application loop (Bubble Tea, etc.) via Pushes().

type ScopeBlock

type ScopeBlock struct {
	UserID string `json:"user_id"`
	TeamID string `json:"team_id"`
	Plan   string `json:"plan"`
}

ScopeBlock is the subset of the welcome envelope's `scope` block the client reads. Mirrors the server's TruestampWeb.ConsoleChannel.welcome_envelope/1 plus the new team_id.

type Status

type Status int

Status describes the lifecycle state of a Client. Returned by Status().

The state machine:

StatusInit         (New() returned, Connect() not called yet)
    │
    ▼
StatusConnecting   ◄────┐  (initial dial + first phx_join)
    │                   │
    ▼                   │
StatusConnected ────────┤  (socket live AND every joined topic re-joined)
    │                   │
    ▼                   │
StatusReconnecting ─────┘  (post-first-connect outage; backing off)
    │
    ▼
StatusClosed       (Close() called; terminal)

StatusConnecting covers both the very first dial AND the welcome-envelope window before topics are replayed. StatusReconnecting is reserved for outages after the first successful connect, so callers can tell "haven't connected yet" apart from "lost the connection".

const (
	StatusInit Status = iota
	StatusConnecting
	StatusConnected
	StatusReconnecting
	StatusClosed
)

func (Status) String

func (s Status) String() string

type SwitchTeamReply

type SwitchTeamReply struct {
	Scope   ScopeBlock        `json:"scope"`
	Team    SwitchedTeam      `json:"team"`
	Role    string            `json:"role"`
	Streams SwitchedStreamSet `json:"streams"`
}

SwitchTeamReply is the success envelope of a `scope.switch_team` channel push. Mirrors the join-time welcome envelope so callers can overwrite their cached welcome state from this struct directly.

type SwitchedStreamSet

type SwitchedStreamSet struct {
	Catalog []string `json:"catalog"`
	Items   []string `json:"items"`
}

SwitchedStreamSet is the post-switch active stream split. `Catalog` stays subscribed (rebound against the new tenant); `Items` are item watches preserved as-is across the switch.

type SwitchedTeam

type SwitchedTeam struct {
	ID             string `json:"id"`
	Name           string `json:"name"`
	Personal       bool   `json:"personal"`
	OwnershipModel string `json:"ownership_model"`
}

SwitchedTeam carries the new team's basic attributes so the client doesn't need a follow-up REST call to render the post-switch UI.

Jump to

Keyboard shortcuts

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