WebSocket Adapter — adapters/websocket¶
See also:
adapters/websocketon pkg.go.dev · Ports — Protocol-Agnostic Wiring · Metrics Observer · Error HandlingRunnable demo:
examples/websocket-duplex— aDuplexPortserved over a real loopback WebSocket: typed commands in, targeted typed replies out,app-supervised lifecycle, observer metrics (upgrade/frame counts + validation failures) injected once viaapp.Options.Observer.
adapters/websocket serves typed, codec-validated frame streams over
persistent bidirectional connections — completing the HTTP story
(IngestAdapter = client→pipeline, SSEAdapter = pipeline→client,
WebSocket = both at once). Endpoints are declared once with
ports.SocketPattern; every frame passes through the port's codec.
Delivery semantics (read first)¶
| Property | WebSocket (this adapter) | MQTT | SSE |
|---|---|---|---|
| Delivery | at-most-once, fire-and-forget | QoS 0/½ | at-most-once |
| Retained / replay | none | retained messages | none |
| Offline clients | frames LOST | QoS 1+ queued | lost |
| Direction | bidirectional | bidirectional (broker) | server → client only |
- A client that is offline or reconnecting loses frames; a late joiner
sees nothing until the next frame. Pair with a
LatestPortwhen "current state on connect" 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
SocketErrorwrappingErrFrameDropped— so one lagging browser tab never blocks the pipeline or other sessions.
Declare the endpoint — ports.SocketPattern¶
var Live = codex.Must(ports.NewDuplexPort[Command, Update]("live",
commandCodec, updateCodec, ports.PortOptions{Buffer: 8}))
_, err := Live.PluginSocketPattern(ports.SocketPattern{Path: "/live/{room}"})
if err != nil {
panic(err)
}
| Field | Meaning |
|---|---|
Path |
HTTP upgrade path template ({var} placeholders, validated once per connection through the rest machinery) |
Subprotocols |
Acceptable Sec-WebSocket-Protocol values (empty = any) |
Format |
Frame wire format from the port's codec: JSON (default), YAML, TOML |
CustomFormat |
Escape hatch for binary/custom frame formats (Gob, protobuf, …) — a pre-built format.Format[T], overrides Format when non-nil. Applies to whichever side(s) carry the real payload type; the unused struct{} side of a one-directional port is unaffected. See ports.FilePattern.CustomFormat for the full contract |
Opts |
rest.RouteOpt entries — PathParam{...}.WithCodec(...) etc., upgrade-time |
InOpts |
ports.SocketInOpt entries (NewRequiredSocketInParam / NewOptionalSocketInParam) — merge connection vars into inbound payloads |
OutOpts |
ports.SocketOutOpt entries (NewRequiredSocketOutParam / NewOptionalSocketOutParam) — merge connection vars into outbound payloads |
Port-type acceptance: SourcePort (inbound-only), SinkPort
(broadcast-only — the WS sibling of SSE), DuplexPort (full duplex).
Rejected on IOPort/LatestPort/ToolPort — per-message request/reply
over a socket is an RPC discipline (ReqReplyPattern territory).
Adapters¶
| Constructor | Port | What it does |
|---|---|---|
IngestSocketAdapter[T](mux, hub, upgrader, handle, opts) ports.SourceAdapter[T] |
SourcePort |
Inbound-only: frames from ALL connected clients feed the port |
BroadcastSocketAdapter[T](mux, hub, upgrader, handle, opts) ports.SinkAdapter[T] |
SinkPort |
Broadcast-only: every port item pushed to all clients |
DuplexSocketAdapter[In,Out](mux, hub, upgrader, handle, opts) ports.DuplexAdapter[In,Out] |
DuplexPort |
Full duplex: session-tagged inbound + targeted/broadcast outbound |
All three share:
- Hub (NewHub(buffer)) — the session registry, constructed in main and
passed to the adapter. hub.SessionInfo(session) exposes upgrade-time
path vars (which {room} a session joined); hub.Sessions() lists peers.
- Upgrader (NewUpgrader(UpgraderOptions{...})) — the gorilla shim.
Keepalive is adapter-owned: ping every 30s, pong wait 60s, 1 MiB read
limit (override via PingInterval/ReadLimit; CheckOrigin for CORS).
- Frame decode failures go to the port's Errors channel — the connection
stays open; one bad frame does not disconnect a client.
Targeted replies (DuplexPort)¶
replies := stream.Map(ctx, Live.Inbound(ctx),
func(f ports.Framed[Command]) (ports.Framed[Update], error) {
return ports.Framed[Update]{
Session: f.Session, // reply to the sender only
Payload: process(f.Payload),
}, nil
}, stream.MapOptions{Name: "ack"})
go Live.Feed(ctx, replies) // zero Session = broadcast
One struct, one call with connection vars¶
WebSocket now supports declare-once merge fields for both directions. Add
InOpts and/or OutOpts on SocketPattern; adapters then merge
connection vars (path/query/header from upgrade request) into payload structs
automatically on each frame:
patterns := []ports.Pattern{
ports.SocketPattern{
Path: "/live/{room}",
Opts: []rest.RouteOpt{
rest.PathParam{Name: "room"}.WithCodec(codex.String()),
},
InOpts: []ports.SocketInOpt{
ports.NewRequiredSocketInParam("room", codex.String(),
func(c Command) string { return c.Room },
func(c *Command, v string) { c.Room = v }),
},
OutOpts: []ports.SocketOutOpt{
ports.NewRequiredSocketOutParam("room", codex.String(),
func(u Update) string { return u.Room },
func(u *Update, v string) { u.Room = v }),
},
},
}
Inbound decode and outbound encode keep the same escape hatch: if no
InOpts/OutOpts are declared, payloads stay untouched.
examples/websocket-duplex shows both approaches side-by-side:
auto-merged room via InOpts/OutOpts and manual hub.SessionInfo(session)
lookup.
Client-side — dial adapters¶
The Dial* family connects OUT to an external WebSocket endpoint (another
go-codex service, a partner API, a feed) and bridges it into the same ports:
| Constructor | Port | Use |
|---|---|---|
DialSourceAdapter[T](dialer, baseURL, vars, handle, opts) |
SourcePort |
consume an external feed |
DialSinkAdapter[T](…) |
SinkPort |
publish outbound frames |
DialDuplexAdapter[In,Out](…) |
DuplexPort |
full duplex client |
NewDialer(DialerOptions{…}) is the gorilla dial shim (same keepalive story
as the server side); the URL is baseURL + the handle's path template
expanded with vars (declared PathParam codecs validate each value).
Reconnect semantics — no silent loss (by design):
- Auto-reconnect with exponential backoff (250ms → 30s cap, reset after a connection that delivered traffic).
- EVERY failed dial and EVERY drop emits a
SocketError(Op"dial"/"read") on the port's Errors channel — consumers KNOW a gap happened and frames may have been missed. - The session generation (
c1,c2, …) advances per connection — a generation change in inbound frames is the visible reconnect marker. - Outbound frames while the connection is down are DROPPED with
ErrFrameDropped(consistent with the server slow-client policy) — including during initial connection establishment: pump or buffer upstream if the first frames matter.
AsyncAPI spec — ports.RegisterSocket¶
OpenAPI cannot express WebSocket frames; AsyncAPI can. RegisterSocket
replays a port's SocketPattern against an events.Builder as a channel:
b := events.NewBuilder(events.Info{Title: "Live Ops Socket", Version: "1.0.0"})
b.AddServer("prod", events.Server{URL: "live.example.com", Protocol: "ws"})
_ = ports.RegisterSocket[Command, Update](b, Live)
doc, _ := b.AsyncAPISpec()
- Channel name = the socket path template;
{var}placeholders become channel parameters. - Subscribe operation = frames the application RECEIVES (
In); Publish operation = frames it SENDS (Out). One-directional ports emit only their live direction (thestruct{}side is skipped). - Built on
events.Builder.AddChannelItem— the escape hatch for channels whose two directions carry different payload types.
(Supplying PortOptions.RESTBuilder still incidentally documents the
upgrade route in OpenAPI as a bare GET — harmless endpoint documentation,
nothing about frames.)
chi variants¶
adapters/chi mirrors all three server adapters with chi-safe registration
(chi.IngestSocketAdapter / chi.BroadcastSocketAdapter /
chi.DuplexSocketAdapter): the swap handler is registered at CONSTRUCTOR
time (chi's Mux cannot register routes while serving) and the real handler
installs atomically at Activate — requests before that get 503. Behaviour
is identical; the implementations delegate to the websocket package.
Narrow client interface¶
Adapters accept Upgrader/Socket — small interfaces — never a gorilla
type. NewUpgrader adapts gorilla/websocket
(already an indirect dependency via mcp-go — zero new modules); socket.go
is the only file importing it. Unit tests run against hand-written fakes.
Errors¶
type SocketError struct {
Path string // declared path template
Session ports.Session // empty for upgrade failures
Op string // "upgrade", "read", "write", "close"
Err error
}
var ErrFrameDropped = ... // sentinel inside SocketError for slow-client drops
Error(), Unwrap(), slog.LogValuer — errors.Is(err, ErrFrameDropped)
works through the chain. Frame validation failures wrap the codec chain
(per-field observer reports, location "payload").
Observer¶
No new stats extension — the transport-agnostic hooks fit:
RecordRequest("GET", path, 101|4xx, dur)once per upgrade attemptRecordSubscribe(path, success, dur)per inbound frameRecordPublish(path, success, dur)per outbound frame- Path-var failures →
RecordValidationError("path", …); frame validation →"payload". Nil observer resolves from ctx.
Error-path ergonomics — ErrorFrame¶
DuplexSocketAdapter and BroadcastSocketAdapter have no synchronous caller
to respond to for upstream pipeline errors (received on the port's stream
Errors channel) — websocket.ErrorFrame is the duplex/broadcast-socket
analogue of
events.ErrorChannel,
adapted to a persistent multi-session transport: instead of publishing to a
declared error topic, the mapped payload is broadcast to every connected
session (there is no dedicated error-output channel on a socket — broadcast
IS the notification path). Both adapters share the exact same ErrorFrame/
ErrorFrameRule declarative surface — DuplexSocketAdapterOptions.ErrorFrames
and BroadcastSocketAdapterOptions.ErrorFrames are both []ErrorFrameRule.
ErrorFrame declares its own codec-backed payload type — 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: the payload is validated via its declared
codec (all Refine constraints run) before being broadcast, exactly like the
happy path.
type ValidationError struct{ Reason string }
func (e ValidationError) Error() string { return "validation: " + e.Reason }
type ErrorPayload struct {
Code string
Message string
}
_ = domain.Live.Bind(ctx, websocket.DuplexSocketAdapter(mux, hub, upgrader, handle,
websocket.DuplexSocketAdapterOptions{
ErrorFrames: []websocket.ErrorFrameRule{
websocket.ErrorFrame[ValidationError, ErrorPayload](errorPayloadCodec,
func(e ValidationError) (ErrorPayload, error) {
return ErrorPayload{Code: "validation", Message: e.Reason}, nil
},
),
},
}))
- Direct mode (no map function):
Emust itself be assignable to the declared payload type. - Mapped mode (map function provided): converts
Einto the declared payload type. - Matching: type-only via
errors.As; the first declaredErrorFrame(in declaration order in theErrorFramesslice) whose type matches wins. ErrorFrameRuleis NOT parameterized byOut— its payload is independently codec-validated and pre-encoded at declaration time, soDuplexSocketAdapterOptions.ErrorFramesis a plain[]websocket.ErrorFrameRule(no type erasure, no runtime type assertion needed atActivatetime).
Action model — respond / handle / log¶
A matched rule executes exactly one action, never an implicit chain:
| Action | Behavior | Default |
|---|---|---|
events.ErrorRespond |
broadcast the encoded typed payload to every connected session | ✅ default |
events.ErrorHandle |
run .WithHandle(func(error)) instead of broadcasting |
opt-in via .WithAction(events.ErrorHandle) |
events.ErrorLog |
forward the error to the port's Errors channel unchanged (same as no match) |
opt-in via .WithAction(events.ErrorLog) |
websocket.ErrorFrame[ValidationError, ErrorPayload](errorPayloadCodec, mapFn).
WithAction(events.ErrorHandle).
WithHandle(func(err error) { log.Printf("duplex error: %v", err) })
Unmatched errors fall through to the existing default: forwarded unchanged to
the port's Errors channel (surfaced via port.Inbound(ctx).Errors). A
matched rule whose mapped payload fails its declared codec's constraints is
ALSO forwarded to the port's Errors channel (never silently broadcast) —
the same terminal-error handling rest.ErrorPattern/events.ErrorChannel
use for a mapping/encoding failure.
Not MQTT-over-WebSocket¶
MQTT clients connecting via ws:// tunnel MQTT frames inside WebSocket —
a transport option of the MQTT client, already supported by passing a
ws:// broker URL to paho (adapters/mqtt/adapters/mqtt5). This adapter
is go-codex itself as the WS server speaking its own typed frames; it is
not an MQTT broker.
Scope¶
Server-side + client-side + chi + AsyncAPI spec are shipped. Still
deferred (awaiting use cases): a ConnectionObserver stats extension and
dynamic subprotocol negotiation — recorded in the
deferred roadmap.