Documentation
¶
Overview ¶
Package websocket provides server-side WebSocket adapters for go-codex ports: typed, codec-validated frame streams over persistent bidirectional connections.
Port mapping ¶
- IngestSocketAdapter — ports.SourceAdapter: inbound-only socket, frames from all connected clients feed the port.
- BroadcastSocketAdapter — ports.SinkAdapter: broadcast-only socket, the WebSocket sibling of the SSE adapter.
- DuplexSocketAdapter — ports.DuplexAdapter: full duplex with session-tagged frames (ports.Framed) — targeted replies + broadcast.
Endpoints are declared once on the port with ports.SocketPattern (path template, subprotocols, frame format); the adapter receives the built ports.Socket handle. Upgrade requests are validated through the rest machinery (path vars, once per connection); every frame is decoded/encoded through the port's codec.
Delivery semantics (read this) ¶
WebSocket delivery is at-most-once with NO retained value: a client that is offline or reconnecting LOSES frames, and a late joiner sees nothing until the next frame. Pair with a LatestPort (send-on-connect) when "current state" matters. Slow clients: each session has a buffered outbound queue (default 16); a full queue DROPS the frame for that session only, reported as a SocketError wrapping ErrFrameDropped — a lagging client never blocks the pipeline or other sessions.
Sessions ¶
Construct one Hub per endpoint (NewHub) and pass it to the adapter. The hub is the session registry: Hub.SessionInfo exposes upgrade-time path vars (e.g. the {room} a session joined) to pipeline code, and Hub.Sessions lists connected peers. Session routing composes with the stream operators — stream.GroupBy by ports.Framed.Session yields per-client sub-streams.
Narrow client interface ¶
Adapters accept Upgrader/Socket — three-method interfaces — never a gorilla type. NewUpgrader adapts gorilla/websocket (keepalive pings, pong deadlines, and read limits are owned by the shim); unit tests use hand-written fakes. socket.go is the only file importing gorilla.
Not MQTT-over-WebSocket ¶
MQTT clients connecting via ws:// tunnel MQTT frames inside WebSocket — that is a transport option of the MQTT CLIENT, already supported by passing a ws:// broker URL to paho (adapters/mqtt, adapters/mqtt5). This package is go-codex ITSELF as the WS server speaking its own typed frames; it does not implement an MQTT broker.
Index ¶
- Variables
- func BroadcastSocketAdapter[T any](mux Mux, hub *Hub, upgrader Upgrader, handle ports.Socket[struct{}, T], ...) ports.SinkAdapter[T]
- func DialDuplexAdapter[In, Out any](dialer Dialer, baseURL string, vars map[string]string, ...) ports.DuplexAdapter[In, Out]
- func DialSinkAdapter[T any](dialer Dialer, baseURL string, vars map[string]string, ...) ports.SinkAdapter[T]
- func DialSourceAdapter[T any](dialer Dialer, baseURL string, vars map[string]string, ...) ports.SourceAdapter[T]
- func DuplexSocketAdapter[In, Out any](mux Mux, hub *Hub, upgrader Upgrader, handle ports.Socket[In, Out], ...) ports.DuplexAdapter[In, Out]
- func IngestSocketAdapter[T any](mux Mux, hub *Hub, upgrader Upgrader, handle ports.Socket[T, struct{}], ...) ports.SourceAdapter[T]
- type BroadcastSocketAdapterOptions
- type DialAdapterOptions
- type Dialer
- type DialerOptions
- type DuplexSocketAdapterOptions
- type ErrorFrameResponse
- type ErrorFrameRule
- type Hub
- type IngestSocketAdapterOptions
- type Mux
- type Socket
- type SocketError
- type Upgrader
- type UpgraderOptions
Examples ¶
Constants ¶
This section is empty.
Variables ¶
var ErrFrameDropped = fmt.Errorf("websocket: outbound queue full, frame dropped")
ErrFrameDropped is the sentinel wrapped in a SocketError when a slow client's outbound queue is full and the frame is dropped (at-most-once delivery policy — a lagging session never blocks the pipeline).
Functions ¶
func BroadcastSocketAdapter ¶
func BroadcastSocketAdapter[T any]( mux Mux, hub *Hub, upgrader Upgrader, handle ports.Socket[struct{}, T], opts BroadcastSocketAdapterOptions, ) ports.SinkAdapter[T]
BroadcastSocketAdapter returns a ports.SinkAdapter that pushes every port item to ALL connected WebSocket clients — the WS sibling of the SSE adapter. Inbound frames from clients are discarded. Use with ports.SinkPort.Bind:
domain.Updates.Bind(ctx, websocket.BroadcastSocketAdapter(
mux, hub, websocket.NewUpgrader(websocket.UpgraderOptions{}),
handle, websocket.BroadcastSocketAdapterOptions{}))
Slow clients: a session whose outbound queue is full has the frame DROPPED for that session only (reported via OnError as a SocketError wrapping ErrFrameDropped); other sessions are unaffected.
func DialDuplexAdapter ¶
func DialDuplexAdapter[In, Out any]( dialer Dialer, baseURL string, vars map[string]string, handle ports.Socket[In, Out], opts DialAdapterOptions, ) ports.DuplexAdapter[In, Out]
DialDuplexAdapter returns a ports.DuplexAdapter that maintains ONE dialed connection to an external duplex endpoint: inbound frames arrive tagged with the current session generation ("c1", "c2", … — a generation CHANGE is the visible reconnect-gap marker); outbound frames go to the live connection regardless of their Session value (there is only one peer) and are DROPPED with ErrFrameDropped while the connection is down.
must0(domain.Upstream.Bind(ctx, websocket.DialDuplexAdapter(
websocket.NewDialer(websocket.DialerOptions{}),
"wss://partner.example.com", map[string]string{"room": "ops"},
handle, websocket.DialAdapterOptions{})))
func DialSinkAdapter ¶
func DialSinkAdapter[T any]( dialer Dialer, baseURL string, vars map[string]string, handle ports.Socket[struct{}, T], opts DialAdapterOptions, ) ports.SinkAdapter[T]
DialSinkAdapter returns a ports.SinkAdapter that dials an external WebSocket endpoint and publishes every port item as a frame. Frames that arrive while the connection is down are DROPPED with a SocketError wrapping ErrFrameDropped (consistent with the server-side slow-client policy); the drop is reported via OnError-style stream error emission to the observer only — SinkAdapter has no error channel, so gaps surface through RecordPublish(success=false).
func DialSourceAdapter ¶
func DialSourceAdapter[T any]( dialer Dialer, baseURL string, vars map[string]string, handle ports.Socket[T, struct{}], opts DialAdapterOptions, ) ports.SourceAdapter[T]
DialSourceAdapter returns a ports.SourceAdapter that dials an external WebSocket endpoint and feeds every decoded inbound frame into the port — consume an external feed. baseURL is scheme+host ("ws://host:port" or "wss://…"); the path comes from the handle's template expanded with vars.
domain.Ticks.Bind(ctx, websocket.DialSourceAdapter(
websocket.NewDialer(websocket.DialerOptions{}),
"wss://feed.example.com", map[string]string{"symbol": "ABC"},
handle, websocket.DialAdapterOptions{}))
Reconnect semantics (BY DESIGN — no silent loss): the adapter auto-reconnects with exponential backoff; every failed attempt and every drop emits a SocketError (Op "dial"/"read") on the port's Errors channel, and the session generation ("c1", "c2", …) advances per connection.
func DuplexSocketAdapter ¶
func DuplexSocketAdapter[In, Out any]( mux Mux, hub *Hub, upgrader Upgrader, handle ports.Socket[In, Out], opts DuplexSocketAdapterOptions, ) ports.DuplexAdapter[In, Out]
DuplexSocketAdapter returns a ports.DuplexAdapter: inbound frames from every connected client arrive session-tagged on the port's Inbound stream; outbound frames fed to the port are delivered to their target session (zero Session = broadcast). Use with ports.DuplexPort.Bind:
must0(domain.Live.Bind(ctx, websocket.DuplexSocketAdapter(
mux, hub, websocket.NewUpgrader(websocket.UpgraderOptions{}),
handle, websocket.DuplexSocketAdapterOptions{})))
Query hub.SessionInfo(session) from pipeline code for the upgrade-time path vars (e.g. the {room} a session joined). Write failures, unknown target sessions, and dropped frames (slow client) surface as SocketError on the port's Errors channel.
Example ¶
package main
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"sync"
"time"
adapterws "github.com/DaniDeer/go-codex/adapters/websocket"
"github.com/DaniDeer/go-codex/codex"
"github.com/DaniDeer/go-codex/ports"
"github.com/DaniDeer/go-codex/validate"
)
type command struct {
Action string
Value int
}
var commandCodec = codex.Struct[command](
codex.RequiredField("action", codex.String().Refine(validate.NonEmptyString),
func(c command) string { return c.Action },
func(c *command, v string) { c.Action = v },
),
codex.RequiredField("value", codex.Int(),
func(c command) int { return c.Value },
func(c *command, v int) { c.Value = v },
),
)
type update struct {
Text string
}
var updateCodec = codex.Struct[update](
codex.RequiredField("text", codex.String(),
func(u update) string { return u.Text },
func(u *update, v string) { u.Text = v },
),
)
// fakeSocket is a scripted, in-memory Socket.
type fakeSocket struct {
mu sync.Mutex
inbound chan []byte
written [][]byte
writeErr error
closed bool
}
func newFakeSocket() *fakeSocket {
return &fakeSocket{inbound: make(chan []byte, 16)}
}
func (f *fakeSocket) ReadMessage() ([]byte, error) {
data, ok := <-f.inbound
if !ok {
return nil, io.EOF
}
return data, nil
}
func (f *fakeSocket) WriteMessage(data []byte) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.writeErr != nil {
return f.writeErr
}
f.written = append(f.written, data)
return nil
}
func (f *fakeSocket) Close() error {
f.mu.Lock()
defer f.mu.Unlock()
if !f.closed {
f.closed = true
close(f.inbound)
}
return nil
}
func (f *fakeSocket) writtenFrames() [][]byte {
f.mu.Lock()
defer f.mu.Unlock()
out := make([][]byte, len(f.written))
copy(out, f.written)
return out
}
// fakeUpgrader hands out scripted sockets in order.
type fakeUpgrader struct {
mu sync.Mutex
socks []*fakeSocket
next int
}
func (f *fakeUpgrader) Upgrade(_ http.ResponseWriter, _ *http.Request) (adapterws.Socket, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.next >= len(f.socks) {
return nil, errors.New("no more sockets scripted")
}
s := f.socks[f.next]
f.next++
return s, nil
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
mux := http.NewServeMux()
hub := adapterws.NewHub(0)
sock := newFakeSocket()
up := &fakeUpgrader{socks: []*fakeSocket{sock}}
port, _ := ports.NewDuplexPort[command, update]("example", commandCodec, updateCodec,
ports.PortOptions{Buffer: 4})
handle, _ := port.PluginSocketPattern(ports.SocketPattern{Path: "/live/{room}"})
_ = port.Bind(ctx, adapterws.DuplexSocketAdapter(mux, hub, up, handle,
adapterws.DuplexSocketAdapterOptions{}))
time.Sleep(20 * time.Millisecond)
// A client connects to /live/kitchen and sends a command.
r := httptest.NewRequest(http.MethodGet, "/live/kitchen", nil)
mux.ServeHTTP(httptest.NewRecorder(), r)
sock.inbound <- []byte(`{"action":"set-temp","value":21}`)
f := <-port.Inbound(ctx).Values
info, _ := hub.SessionInfo(f.Session)
fmt.Printf("%s in room %s wants %d\n", f.Payload.Action, info["room"], f.Payload.Value)
}
Output: set-temp in room kitchen wants 21
func IngestSocketAdapter ¶
func IngestSocketAdapter[T any]( mux Mux, hub *Hub, upgrader Upgrader, handle ports.Socket[T, struct{}], opts IngestSocketAdapterOptions, ) ports.SourceAdapter[T]
IngestSocketAdapter returns a ports.SourceAdapter that accepts WebSocket connections on the handle's path and feeds every decoded inbound frame (from ALL connected clients) into the port. The inbound-only socket — the server never pushes. Use with ports.SourcePort.Bind:
domain.Commands.Bind(ctx, websocket.IngestSocketAdapter(
mux, hub, websocket.NewUpgrader(websocket.UpgraderOptions{}),
handle, websocket.IngestSocketAdapterOptions{}))
Frame decode/validation failures go to the port's Errors channel as SocketError (per-field reports with location "payload"); the connection stays open — one bad frame does not disconnect a client.
Types ¶
type BroadcastSocketAdapterOptions ¶
type BroadcastSocketAdapterOptions struct {
// OnError receives encode failures, dropped-frame notices
// ([ErrFrameDropped]), and unmatched/log-action upstream stream errors.
OnError func(error)
// ErrorFrames declares typed error patterns via [ErrorFrame] — same
// declarative surface [DuplexSocketAdapterOptions.ErrorFrames] uses.
// When a rule matches an error received on the port's stream Errors
// channel, the resolved action determines behaviour — see [ErrorFrame].
// Per-session write/encode failures (already [SocketError]-wrapped) are
// NOT matched against ErrorFrames — only upstream stream errors are.
ErrorFrames []ErrorFrameRule
// Observer receives per-connection RecordRequest and per-frame
// RecordPublish events. Resolved from ctx when nil.
Observer stats.Observer
}
BroadcastSocketAdapterOptions configures BroadcastSocketAdapter.
type DialAdapterOptions ¶
type DialAdapterOptions struct {
// MaxBackoff caps the exponential reconnect backoff. Default 30s
// (initial step 250ms, doubling per consecutive failure, reset after
// a successful read).
MaxBackoff time.Duration
// Observer receives RecordRequest per dial attempt and
// RecordSubscribe/RecordPublish per frame. Resolved from ctx when nil.
Observer stats.Observer
}
DialAdapterOptions configures the Dial*-family adapters.
type Dialer ¶
Dialer is the narrow client-side surface: it opens a Socket to a ws:// or wss:// URL. NewDialer adapts gorilla/websocket; tests provide fakes.
func NewDialer ¶
func NewDialer(opts DialerOptions) Dialer
NewDialer returns a Dialer backed by gorilla/websocket. Dialed connections run the same shim-owned keepalive as server connections (ping every PingInterval, pong deadline, ReadLimit).
type DialerOptions ¶
type DialerOptions struct {
// Subprotocols lists requested Sec-WebSocket-Protocol values.
Subprotocols []string
// RequestHeader is sent with the upgrade request (auth tokens, …).
RequestHeader http.Header
// PingInterval is the client keepalive cadence. Default 30s.
PingInterval time.Duration
// ReadLimit caps inbound frame size in bytes. Default 1 MiB.
ReadLimit int64
}
DialerOptions configures NewDialer.
type DuplexSocketAdapterOptions ¶
type DuplexSocketAdapterOptions struct {
// Observer receives per-connection RecordRequest, per-inbound-frame
// RecordSubscribe, and per-outbound-frame RecordPublish events.
// Resolved from ctx when nil.
Observer stats.Observer
// ErrorFrames declares typed error patterns via [ErrorFrame] — each rule
// carries its own independently codec-validated payload (pre-encoded),
// so no type erasure or runtime type assertion against the socket's Out
// type is needed. When a rule matches an error received on the port's
// outbound stream Errors channel, the resolved action determines
// behaviour — see [ErrorFrame].
ErrorFrames []ErrorFrameRule
}
DuplexSocketAdapterOptions configures DuplexSocketAdapter.
type ErrorFrameResponse ¶
type ErrorFrameResponse struct {
// Body is the JSON-encoded typed error payload.
Body []byte
// Value is the typed payload before encoding.
Value any
// Action is the resolved action for the matched rule.
Action events.ErrorAction
}
ErrorFrameResponse is the adapter-ready payload produced by a matched ErrorFrame rule — the typed payload to broadcast, already encoded, plus the resolved action.
type ErrorFrameRule ¶
type ErrorFrameRule struct {
// contains filtered or unexported fields
}
ErrorFrameRule is the value returned by ErrorFrame — pass a slice of these to DuplexSocketAdapterOptions.ErrorFrames. Unlike the pre-codec design, ErrorFrameRule is NOT parameterized by the socket's Out type — its payload is independently codec-validated and pre-encoded, so it needs no type-erased storage or runtime type assertion at Activate time.
func ErrorFrame ¶
func ErrorFrame[E error, B any]( codec codex.Codec[B], mapFn ...func(E) (B, error), ) ErrorFrameRule
ErrorFrame declares, for a DuplexSocketAdapter, how a matched upstream pipeline error (received on the port's outbound stream Errors channel) is realized on the socket — the WebSocket analogue of events.ErrorChannel, adapted to a persistent multi-session transport that has no dedicated error-output topic: broadcast to every connected session IS the notification path.
ErrorFrame declares its OWN codec-backed payload type B — independent of the socket's happy-path Out frame type — the same "one-struct-one-call" guarantee rest.ErrorPattern/events.ErrorChannel/[reqreply.ErrorPattern]/ [mcp.ErrorPattern] all provide: B is validated via its declared codec (all Refine constraints run) before being broadcast, exactly like the happy path.
Two modes, mirroring events.ErrorChannel:
- Direct: no mapFn provided, E must be assignable to B.
- Mapped: mapFn(E) produces B.
Matching is type-only via errors.As; the first declared ErrorFrame (in the DuplexSocketAdapterOptions.ErrorFrames slice) whose type matches wins — the same deterministic precedence used by REST/events error patterns.
The default action is events.ErrorRespond: the mapped B value is broadcast to every connected session. Use ErrorFrameRule.WithAction to select events.ErrorHandle (Handle runs instead, no broadcast) or events.ErrorLog (the error is forwarded to the port's Errors channel unchanged — the same as when no rule matches).
websocket.DuplexSocketAdapterOptions{
ErrorFrames: []websocket.ErrorFrameRule{
websocket.ErrorFrame[domain.ValidationError, ErrorPayload](errorPayloadCodec,
func(e domain.ValidationError) (ErrorPayload, error) {
return ErrorPayload{Code: "validation", Message: e.Error()}, nil
},
),
},
}
func (ErrorFrameRule) WithAction ¶
func (r ErrorFrameRule) WithAction(action events.ErrorAction) ErrorFrameRule
WithAction returns a copy of r with Action set to action, overriding the default events.ErrorRespond. A matched pattern executes exactly one action — never an implicit handle-then-respond chain.
func (ErrorFrameRule) WithHandle ¶
func (r ErrorFrameRule) WithHandle(handle func(error)) ErrorFrameRule
WithHandle sets the callback invoked (with the original error) when this rule matches and its Action is events.ErrorHandle. No broadcast occurs in that case — the callback fully owns the error.
type Hub ¶
type Hub struct {
// contains filtered or unexported fields
}
Hub is the session registry shared between an adapter and the caller. Construct one per socket endpoint with NewHub and pass it to the adapter constructor; query Hub.SessionInfo from pipeline code to route by upgrade-time path vars (e.g. the {room} a session joined).
Delivery policy (by design): each session has a buffered outbound queue (default 16). A full queue DROPS the frame and reports a SocketError — a slow client never blocks the pipeline or other sessions (the stream.BroadcastHub precedent).
func NewHub ¶
NewHub creates a Hub. buffer is the per-session outbound queue size; <= 0 means the default 16.
func (*Hub) SessionInfo ¶
SessionInfo returns the upgrade-time path vars recorded for a session (e.g. {"room": "a"}), or (nil, false) when the session is not connected.
type IngestSocketAdapterOptions ¶
type IngestSocketAdapterOptions struct {
// Observer receives per-connection RecordRequest and per-frame
// RecordSubscribe events. Resolved from ctx when nil.
Observer stats.Observer
}
IngestSocketAdapterOptions configures IngestSocketAdapter.
type Mux ¶
Mux is the subset of *http.ServeMux the adapters use for route registration (Go 1.22 pattern syntax, e.g. "GET /live/{room}").
type Socket ¶
type Socket interface {
ReadMessage() ([]byte, error)
WriteMessage(data []byte) error
Close() error
}
Socket is the narrow per-connection surface the adapters use. Constructors accept Upgrader/Socket — never a concrete client type — so unit tests run against hand-written fakes and the adapter stays decoupled from gorilla.
ReadMessage blocks until a frame arrives, the peer closes, or the connection breaks. WriteMessage must be safe for use from the hub's single writer goroutine (one writer per connection — the adapter guarantees no concurrent writes). Close performs the close handshake; it must be safe to call concurrently with a blocked ReadMessage (unblocking it).
type SocketError ¶
type SocketError struct {
// Path is the declared upgrade path template (e.g. "/live/{room}").
Path string
// Session identifies the affected peer. Empty for upgrade failures
// (no session exists yet) and broadcast-level errors.
Session ports.Session
// Op is the operation: "upgrade", "read", "write", or "close".
Op string
// Err is the underlying error. For a dropped frame (slow client) this
// is [ErrFrameDropped].
Err error
}
SocketError wraps a websocket operation failure.
func (SocketError) Error ¶
func (e SocketError) Error() string
func (SocketError) LogValue ¶
func (e SocketError) LogValue() slog.Value
LogValue implements slog.LogValuer for structured logging.
type Upgrader ¶
Upgrader upgrades an HTTP request to a Socket. NewUpgrader adapts gorilla/websocket; tests provide fakes.
func NewUpgrader ¶
func NewUpgrader(opts UpgraderOptions) Upgrader
NewUpgrader returns an Upgrader backed by gorilla/websocket. Each upgraded connection runs an adapter-owned keepalive: pings every PingInterval, expects a pong within 2×PingInterval, and enforces ReadLimit. This file is the only place the adapter touches gorilla.
type UpgraderOptions ¶
type UpgraderOptions struct {
// Subprotocols lists acceptable Sec-WebSocket-Protocol values.
// Usually taken from the port's [ports.Socket].Subprotocols.
Subprotocols []string
// PingInterval is the keepalive ping cadence. Default 30s.
PingInterval time.Duration
// ReadLimit caps inbound frame size in bytes. Default 1 MiB.
ReadLimit int64
// CheckOrigin overrides gorilla's same-origin policy. Nil keeps the
// default (reject cross-origin browser connections).
CheckOrigin func(r *http.Request) bool
}
UpgraderOptions configures NewUpgrader.