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
- type ChannelError
- type ChannelErrorCode
- type Client
- func (c *Client) Close() error
- func (c *Client) Connect(ctx context.Context) (json.RawMessage, error)
- func (c *Client) JoinTopic(ctx context.Context, topic string) (json.RawMessage, error)
- func (c *Client) Push(ctx context.Context, topic, event string, payload any) (PhxReply, error)
- func (c *Client) Pushes() <-chan Push
- func (c *Client) Status() Status
- func (c *Client) SwitchTeam(ctx context.Context, teamID string) (*SwitchTeamReply, error)
- type Frame
- type Options
- type PhxReply
- type Push
- type ScopeBlock
- type Status
- type SwitchTeamReply
- type SwitchedStreamSet
- type SwitchedTeam
Constants ¶
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.
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:
- New(opts) returns a configured client (no I/O).
- Connect(ctx) opens the socket and joins the primary topic; returns the join reply.
- JoinTopic(ctx, topic) joins additional topics on the same socket.
- 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.
- Pushes() yields server-initiated events from every joined topic; consumers route on Push.Topic.
- 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 (*Client) Close ¶
Close shuts down the client and underlying socket cleanly. Safe to call multiple times.
func (*Client) Connect ¶
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 ¶
JoinTopic joins an additional channel topic on the existing socket. Returns the raw join reply payload. Safe to call after Connect.
func (*Client) Push ¶
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 ¶
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 ¶
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 ¶
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 ¶
MarshalJSON encodes the frame as the canonical 5-element array.
func (*Frame) UnmarshalJSON ¶
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 ¶
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".
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 ¶
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.