websocket

package
v0.12.0 Latest Latest
Warning

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

Go to latest
Published: Jul 29, 2026 License: MIT Imports: 17 Imported by: 0

Documentation

Overview

Package websocket provides server-side WebSocket adapters for go-codex ports: typed, codec-validated frame streams over persistent bidirectional connections.

Port mapping

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

Examples

Constants

This section is empty.

Variables

View Source
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

type Dialer interface {
	Dial(ctx context.Context, url string) (Socket, error)
}

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

func NewHub(buffer int) *Hub

NewHub creates a Hub. buffer is the per-session outbound queue size; <= 0 means the default 16.

func (*Hub) SessionInfo

func (h *Hub) SessionInfo(s ports.Session) (map[string]string, bool)

SessionInfo returns the upgrade-time path vars recorded for a session (e.g. {"room": "a"}), or (nil, false) when the session is not connected.

func (*Hub) Sessions

func (h *Hub) Sessions() []ports.Session

Sessions returns the currently connected session IDs.

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

type Mux interface {
	Handle(pattern string, handler http.Handler)
}

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.

func (SocketError) Unwrap

func (e SocketError) Unwrap() error

Unwrap allows errors.Is and errors.As to reach the underlying error.

type Upgrader

type Upgrader interface {
	Upgrade(w http.ResponseWriter, r *http.Request) (Socket, error)
}

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.

Jump to

Keyboard shortcuts

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